markdown-to-confluence 0.3.2__py3-none-any.whl → 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -62,13 +62,13 @@ Whenever possible, the implementation uses [Confluence REST API v2](https://deve
62
62
 
63
63
  ## Installation
64
64
 
65
- Install the core package from PyPI:
65
+ **Required.** Install the core package from [PyPI](https://pypi.org/project/markdown-to-confluence/):
66
66
 
67
67
  ```sh
68
68
  pip install markdown-to-confluence
69
69
  ```
70
70
 
71
- Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
71
+ **Optional.** Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
72
72
 
73
73
  ```sh
74
74
  npm install -g @mermaid-js/mermaid-cli
@@ -167,7 +167,7 @@ The concepts above are illustrated in the following sections.
167
167
 
168
168
  #### File-system directory hierarchy
169
169
 
170
- 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.
171
171
 
172
172
  ```
173
173
  .
@@ -198,12 +198,37 @@ root
198
198
  └── Mean vs. median
199
199
  ```
200
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
+
201
209
  ### Ignoring files
202
210
 
203
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.
204
212
 
205
213
  Files that don't have the extension `*.md` are skipped automatically. Hidden directories (whose name starts with `.`) are not recursed into.
206
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
+
225
+ ### Converting diagrams
226
+
227
+ You can include [Mermaid diagrams](https://mermaid.js.org/) in your Markdown documents to create visual representations of systems, processes, and relationships. When a Markdown document contains a code block with the language specifier `mermaid`, *md2conf* offers two options to publish the diagram:
228
+
229
+ 1. Pre-render into an image. The code block is interpreted by and converted into a PNG or SVG image with the Mermaid diagram utility [mermaid-cli](https://github.com/mermaid-js/mermaid-cli). The generated image is then uploaded to Confluence as an attachment to the page. This is the approach we use and support.
230
+ 2. Render on demand. The code block is transformed into a [diagram macro](https://atlasauthority.atlassian.net/wiki/spaces/MARKDOWNCLOUD/pages/2946826241/Diagram+Macro), which is processed by Confluence. You need a [Confluence plugin](https://marketplace.atlassian.com/apps/1211438/markdown-html-plantuml-latex-diagrams-open-api-mermaid) to turn macro definitions into images when a Confluence page is visited. This is a contributed feature. As authors of *md2conf*, we don't endorse or support any particular Confluence plugin.
231
+
207
232
  ### Running the tool
208
233
 
209
234
  You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
@@ -216,10 +241,8 @@ Use the `--help` switch to get a full list of supported command-line options:
216
241
 
217
242
  ```console
218
243
  $ python3 -m md2conf --help
219
- usage: md2conf [-h] [--version] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE]
220
- [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY] [--no-generated-by]
221
- [--render-mermaid] [--no-render-mermaid] [--render-mermaid-format {png,svg}] [--heading-anchors]
222
- [--ignore-invalid-url] [--local] [--headers [KEY=VALUE ...]] [--webui-links]
244
+ 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]
245
+ [--render-mermaid] [--no-render-mermaid] [--render-mermaid-format {png,svg}] [--heading-anchors] [--ignore-invalid-url] [--local] [--headers [KEY=VALUE ...]] [--webui-links]
223
246
  mdpath
224
247
 
225
248
  positional arguments:
@@ -240,6 +263,7 @@ options:
240
263
  -l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
241
264
  Use this option to set the log verbosity.
242
265
  -r ROOT_PAGE Root Confluence page to create new pages. If omitted, will raise exception when creating new pages.
266
+ --keep-hierarchy Maintain source directory structure when exporting to Confluence.
243
267
  --generated-by GENERATED_BY
244
268
  Add prompt to pages (default: 'This page has been generated with a tool.').
245
269
  --no-generated-by Do not add 'generated by a tool' prompt to pages.
@@ -0,0 +1,22 @@
1
+ markdown_to_confluence-0.3.4.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
2
+ md2conf/__init__.py,sha256=9gI6OYCv9-54FzxjNHLOH09H5quUDEMWq9pdbhnwoXM,402
3
+ md2conf/__main__.py,sha256=bFcfmSnTWeuhmDm7bJ3jJabZ2S8W9biuAP6_R-Cc9As,8034
4
+ md2conf/api.py,sha256=ZIYoBXclLbzrrQ_oFRllsTEnQIMbxqd9OD80-AC5qM0,22769
5
+ md2conf/application.py,sha256=eIVeAGUzfdIq1uYLYpTg30UNSq-YcUIY-OgKKK3M4E4,6436
6
+ md2conf/converter.py,sha256=2Sgq1WQd-dCtrdTVrBwhowPC8PmubMNCH1aAcRwntjs,39404
7
+ md2conf/emoji.py,sha256=48QJtOD0F3Be1laYLvAOwe0GxrJS-vcfjtCdiBsNcAc,1960
8
+ md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
9
+ md2conf/local.py,sha256=AOuwyvPOXrRRPGOTDeoVYkMPJ9MI2zqRGAvHuY35wy4,3884
10
+ md2conf/matcher.py,sha256=FgMFPvGiOqGezCs8OyerfsVo-iIHFoI6LRMzdcjM5UY,3693
11
+ md2conf/mermaid.py,sha256=un_KHBDpG5Zad_QD3HN1uBwUxp4I-HVJYhNKbH7KwcA,2312
12
+ md2conf/metadata.py,sha256=9BtNRsICbKzPTs63P70XekNARePdW1DtdKNJqXh2ZFM,1013
13
+ md2conf/processor.py,sha256=Ko_3WqLK6jM-bEN7OD9Vc3g3vhSjRYawz3fG6uoUsXc,6733
14
+ md2conf/properties.py,sha256=TOCXLdTfYkKjRwZaMgvXw0mNCI4opEUwpBXro2Kv2B4,2467
15
+ md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
16
+ md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ markdown_to_confluence-0.3.4.dist-info/METADATA,sha256=PUtJXudDooVfwOzVtohxweWHMjgDv5CIrDvyqiJ0tlg,17745
18
+ markdown_to_confluence-0.3.4.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
19
+ markdown_to_confluence-0.3.4.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
20
+ markdown_to_confluence-0.3.4.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
21
+ markdown_to_confluence-0.3.4.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
22
+ markdown_to_confluence-0.3.4.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (80.8.0)
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.2"
8
+ __version__ = "0.3.4"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
11
11
  __license__ = "MIT"
md2conf/__main__.py CHANGED
@@ -22,18 +22,23 @@ import requests
22
22
  from . import __version__
23
23
  from .api import ConfluenceAPI
24
24
  from .application import Application
25
- from .converter import ConfluenceDocumentOptions
26
- from .processor import Processor
27
- from .properties import ConfluenceProperties
25
+ from .converter import ConfluenceDocumentOptions, ConfluencePageID
26
+ from .local import LocalConverter
27
+ from .metadata import ConfluenceSiteMetadata
28
+ from .properties import (
29
+ ArgumentError,
30
+ ConfluenceConnectionProperties,
31
+ ConfluenceSiteProperties,
32
+ )
28
33
 
29
34
 
30
35
  class Arguments(argparse.Namespace):
31
36
  mdpath: Path
32
- domain: str
33
- path: str
34
- username: str
35
- apikey: str
36
- space: str
37
+ domain: Optional[str]
38
+ path: Optional[str]
39
+ username: Optional[str]
40
+ apikey: Optional[str]
41
+ space: Optional[str]
37
42
  loglevel: str
38
43
  ignore_invalid_url: bool
39
44
  heading_anchors: bool
@@ -195,24 +200,45 @@ def main() -> None:
195
200
  heading_anchors=args.heading_anchors,
196
201
  ignore_invalid_url=args.ignore_invalid_url,
197
202
  generated_by=args.generated_by,
198
- root_page_id=args.root_page,
203
+ root_page_id=ConfluencePageID(args.root_page) if args.root_page else None,
199
204
  keep_hierarchy=args.keep_hierarchy,
200
205
  render_mermaid=args.render_mermaid,
201
206
  diagram_output_format=args.diagram_output_format,
202
207
  webui_links=args.webui_links,
203
208
  )
204
- properties = ConfluenceProperties(
205
- args.domain, args.path, args.username, args.apikey, args.space, args.headers
206
- )
207
209
  if args.local:
208
- Processor(options, properties).process(args.mdpath)
210
+ try:
211
+ site_properties = ConfluenceSiteProperties(
212
+ domain=args.domain,
213
+ base_path=args.path,
214
+ space_key=args.space,
215
+ )
216
+ except ArgumentError as e:
217
+ parser.error(str(e))
218
+ site_metadata = ConfluenceSiteMetadata(
219
+ domain=site_properties.domain,
220
+ base_path=site_properties.base_path,
221
+ space_key=site_properties.space_key,
222
+ )
223
+ LocalConverter(options, site_metadata).process(args.mdpath)
209
224
  else:
225
+ try:
226
+ properties = ConfluenceConnectionProperties(
227
+ args.domain,
228
+ args.path,
229
+ args.username,
230
+ args.apikey,
231
+ args.space,
232
+ args.headers,
233
+ )
234
+ except ArgumentError as e:
235
+ parser.error(str(e))
210
236
  try:
211
237
  with ConfluenceAPI(properties) as api:
212
238
  Application(
213
239
  api,
214
240
  options,
215
- ).synchronize(args.mdpath)
241
+ ).process(args.mdpath)
216
242
  except requests.exceptions.HTTPError as err:
217
243
  logging.error(err)
218
244
 
md2conf/api.py CHANGED
@@ -7,6 +7,7 @@ Copyright 2022-2025, Levente Hunyadi
7
7
  """
8
8
 
9
9
  import enum
10
+ import functools
10
11
  import io
11
12
  import json
12
13
  import logging
@@ -21,7 +22,13 @@ from urllib.parse import urlencode, urlparse, urlunparse
21
22
  import requests
22
23
 
23
24
  from .converter import ParseError, sanitize_confluence
24
- from .properties import ConfluenceError, ConfluenceProperties
25
+ from .metadata import ConfluenceSiteMetadata
26
+ from .properties import (
27
+ ArgumentError,
28
+ ConfluenceConnectionProperties,
29
+ ConfluenceError,
30
+ PageError,
31
+ )
25
32
 
26
33
  # a JSON type with possible `null` values
27
34
  JsonType = Union[
@@ -44,6 +51,7 @@ class ConfluencePageParentContentType(enum.Enum):
44
51
  """
45
52
  Content types that can be a parent to a Confluence page
46
53
  """
54
+
47
55
  PAGE = "page"
48
56
  WHITEBOARD = "whiteboard"
49
57
  DATABASE = "database"
@@ -70,7 +78,7 @@ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
70
78
  LOGGER = logging.getLogger(__name__)
71
79
 
72
80
 
73
- @dataclass
81
+ @dataclass(frozen=True)
74
82
  class ConfluenceAttachment:
75
83
  id: str
76
84
  media_type: str
@@ -78,23 +86,29 @@ class ConfluenceAttachment:
78
86
  comment: str
79
87
 
80
88
 
81
- @dataclass
82
- class ConfluencePage:
89
+ @dataclass(frozen=True)
90
+ class ConfluencePageMetadata:
83
91
  id: str
84
92
  space_id: str
85
93
  parent_id: str
86
- parent_type: ConfluencePageParentContentType
94
+ parent_type: Optional[ConfluencePageParentContentType]
87
95
  title: str
88
96
  version: int
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ConfluencePage(ConfluencePageMetadata):
89
101
  content: str
90
102
 
91
103
 
92
104
  class ConfluenceAPI:
93
- properties: ConfluenceProperties
105
+ properties: ConfluenceConnectionProperties
94
106
  session: Optional["ConfluenceSession"] = None
95
107
 
96
- def __init__(self, properties: Optional[ConfluenceProperties] = None) -> None:
97
- self.properties = properties or ConfluenceProperties()
108
+ def __init__(
109
+ self, properties: Optional[ConfluenceConnectionProperties] = None
110
+ ) -> None:
111
+ self.properties = properties or ConfluenceConnectionProperties()
98
112
 
99
113
  def __enter__(self) -> "ConfluenceSession":
100
114
  session = requests.Session()
@@ -128,10 +142,12 @@ class ConfluenceAPI:
128
142
 
129
143
 
130
144
  class ConfluenceSession:
145
+ """
146
+ Information about an open session to a Confluence server.
147
+ """
148
+
131
149
  session: requests.Session
132
- domain: str
133
- base_path: str
134
- space_key: Optional[str]
150
+ site: ConfluenceSiteMetadata
135
151
 
136
152
  _space_id_to_key: dict[str, str]
137
153
  _space_key_to_id: dict[str, str]
@@ -141,12 +157,10 @@ class ConfluenceSession:
141
157
  session: requests.Session,
142
158
  domain: str,
143
159
  base_path: str,
144
- space_key: Optional[str],
160
+ space_key: Optional[str] = None,
145
161
  ) -> None:
146
162
  self.session = session
147
- self.domain = domain
148
- self.base_path = base_path
149
- self.space_key = space_key
163
+ self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
150
164
 
151
165
  self._space_id_to_key = {}
152
166
  self._space_key_to_id = {}
@@ -170,7 +184,9 @@ class ConfluenceSession:
170
184
  :returns: A full URL.
171
185
  """
172
186
 
173
- base_url = f"https://{self.domain}{self.base_path}{version.value}{path}"
187
+ base_url = (
188
+ f"https://{self.site.domain}{self.site.base_path}{version.value}{path}"
189
+ )
174
190
  return build_url(base_url, query)
175
191
 
176
192
  def _invoke(
@@ -183,11 +199,9 @@ class ConfluenceSession:
183
199
 
184
200
  url = self._build_url(version, path, query)
185
201
  response = self.session.get(url)
186
- response.raise_for_status()
187
- if len(response.text) > 240:
188
- LOGGER.debug("Received HTTP payload (truncated):\n%.240s...", response.text)
189
- else:
202
+ if response.text:
190
203
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
204
+ response.raise_for_status()
191
205
  return response.json()
192
206
 
193
207
  def _save(self, version: ConfluenceVersion, path: str, data: dict) -> None:
@@ -197,6 +211,8 @@ class ConfluenceSession:
197
211
  data=json.dumps(data),
198
212
  headers={"Content-Type": "application/json"},
199
213
  )
214
+ if response.text:
215
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
200
216
  response.raise_for_status()
201
217
 
202
218
  def space_id_to_key(self, id: str) -> str:
@@ -243,6 +259,29 @@ class ConfluenceSession:
243
259
 
244
260
  return id
245
261
 
262
+ def get_space_id(
263
+ self, *, space_id: Optional[str] = None, space_key: Optional[str] = None
264
+ ) -> Optional[str]:
265
+ """
266
+ Coalesce a space ID or space key into a space ID, accounting for site default.
267
+
268
+ :param space_id: A Confluence space ID.
269
+ :param space_key: A Confluence space key.
270
+ """
271
+
272
+ if space_id is not None and space_key is not None:
273
+ raise ConfluenceError("either space ID or space key is required; not both")
274
+
275
+ if space_id is not None:
276
+ return space_id
277
+
278
+ space_key = space_key or self.site.space_key
279
+ if space_key is not None:
280
+ return self.space_key_to_id(space_key)
281
+
282
+ # space ID and key are unset, and no default space is configured
283
+ return None
284
+
246
285
  def get_attachment_by_name(
247
286
  self, page_id: str, filename: str
248
287
  ) -> ConfluenceAttachment:
@@ -275,10 +314,10 @@ class ConfluenceSession:
275
314
  force: bool = False,
276
315
  ) -> None:
277
316
  if attachment_path is None and raw_data is None:
278
- raise ConfluenceError("required: `attachment_path` or `raw_data`")
317
+ raise ArgumentError("required: `attachment_path` or `raw_data`")
279
318
 
280
319
  if attachment_path is not None and raw_data is not None:
281
- raise ConfluenceError("expected: either `attachment_path` or `raw_data`")
320
+ raise ArgumentError("expected: either `attachment_path` or `raw_data`")
282
321
 
283
322
  if content_type is None:
284
323
  if attachment_path is not None:
@@ -288,7 +327,7 @@ class ConfluenceSession:
288
327
  content_type, _ = mimetypes.guess_type(name, strict=True)
289
328
 
290
329
  if attachment_path is not None and not attachment_path.is_file():
291
- raise ConfluenceError(f"file not found: {attachment_path}")
330
+ raise PageError(f"file not found: {attachment_path}")
292
331
 
293
332
  try:
294
333
  attachment = self.get_attachment_by_name(page_id, attachment_name)
@@ -385,6 +424,7 @@ class ConfluenceSession:
385
424
  self,
386
425
  title: str,
387
426
  *,
427
+ space_id: Optional[str] = None,
388
428
  space_key: Optional[str] = None,
389
429
  ) -> str:
390
430
  """
@@ -400,9 +440,9 @@ class ConfluenceSession:
400
440
  query = {
401
441
  "title": title,
402
442
  }
403
- coalesced_space_key = space_key or self.space_key
404
- if coalesced_space_key is not None:
405
- query["space-id"] = self.space_key_to_id(coalesced_space_key)
443
+ space_id = self.get_space_id(space_id=space_id, space_key=space_key)
444
+ if space_id is not None:
445
+ query["space-id"] = space_id
406
446
 
407
447
  payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
408
448
  payload = typing.cast(dict[str, JsonType], payload)
@@ -417,10 +457,10 @@ class ConfluenceSession:
417
457
 
418
458
  def get_page(self, page_id: str) -> ConfluencePage:
419
459
  """
420
- Retrieve Confluence wiki page details.
460
+ Retrieve Confluence wiki page details and content.
421
461
 
422
462
  :param page_id: The Confluence page ID.
423
- :returns: Confluence page info.
463
+ :returns: Confluence page info and content.
424
464
  """
425
465
 
426
466
  path = f"/pages/{page_id}"
@@ -435,12 +475,43 @@ class ConfluenceSession:
435
475
  id=page_id,
436
476
  space_id=typing.cast(str, data["spaceId"]),
437
477
  parent_id=typing.cast(str, data["parentId"]),
438
- parent_type=ConfluencePageParentContentType(typing.cast(str, data["parentType"])),
478
+ parent_type=(
479
+ ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
480
+ if data["parentType"] is not None
481
+ else None
482
+ ),
439
483
  title=typing.cast(str, data["title"]),
440
484
  version=typing.cast(int, version["number"]),
441
485
  content=typing.cast(str, storage["value"]),
442
486
  )
443
487
 
488
+ @functools.cache
489
+ def get_page_metadata(self, page_id: str) -> ConfluencePageMetadata:
490
+ """
491
+ Retrieve Confluence wiki page details.
492
+
493
+ :param page_id: The Confluence page ID.
494
+ :returns: Confluence page info.
495
+ """
496
+
497
+ path = f"/pages/{page_id}"
498
+ payload = self._invoke(ConfluenceVersion.VERSION_2, path)
499
+ data = typing.cast(dict[str, JsonType], payload)
500
+ version = typing.cast(dict[str, JsonType], data["version"])
501
+
502
+ return ConfluencePageMetadata(
503
+ id=page_id,
504
+ space_id=typing.cast(str, data["spaceId"]),
505
+ parent_id=typing.cast(str, data["parentId"]),
506
+ parent_type=(
507
+ ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
508
+ if data["parentType"] is not None
509
+ else None
510
+ ),
511
+ title=typing.cast(str, data["title"]),
512
+ version=typing.cast(int, version["number"]),
513
+ )
514
+
444
515
  def get_page_version(self, page_id: str) -> int:
445
516
  """
446
517
  Retrieve a Confluence wiki page version.
@@ -495,28 +566,21 @@ class ConfluenceSession:
495
566
 
496
567
  def create_page(
497
568
  self,
498
- parent_page_id: str,
569
+ parent_id: str,
499
570
  title: str,
500
571
  new_content: str,
501
- *,
502
- space_key: Optional[str] = None,
503
572
  ) -> ConfluencePage:
504
573
  """
505
574
  Create a new page via Confluence API.
506
575
  """
507
576
 
508
- coalesced_space_key = space_key or self.space_key
509
- if coalesced_space_key is None:
510
- raise ConfluenceError(
511
- "Confluence space key required for creating a new page"
512
- )
513
-
577
+ parent_page = self.get_page_metadata(parent_id)
514
578
  path = "/pages/"
515
579
  query = {
516
- "spaceId": self.space_key_to_id(coalesced_space_key),
580
+ "spaceId": parent_page.space_id,
517
581
  "status": "current",
518
582
  "title": title,
519
- "parentId": parent_page_id,
583
+ "parentId": parent_id,
520
584
  "body": {"storage": {"value": new_content, "representation": "storage"}},
521
585
  }
522
586
 
@@ -539,7 +603,11 @@ class ConfluenceSession:
539
603
  id=typing.cast(str, data["id"]),
540
604
  space_id=typing.cast(str, data["spaceId"]),
541
605
  parent_id=typing.cast(str, data["parentId"]),
542
- parent_type=ConfluencePageParentContentType(typing.cast(str, data["parentType"])),
606
+ parent_type=(
607
+ ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
608
+ if data["parentType"] is not None
609
+ else None
610
+ ),
543
611
  title=typing.cast(str, data["title"]),
544
612
  version=typing.cast(int, version["number"]),
545
613
  content=typing.cast(str, storage["value"]),
@@ -570,13 +638,24 @@ class ConfluenceSession:
570
638
  response.raise_for_status()
571
639
 
572
640
  def page_exists(
573
- self, title: str, *, space_key: Optional[str] = None
641
+ self,
642
+ title: str,
643
+ *,
644
+ space_id: Optional[str] = None,
645
+ space_key: Optional[str] = None,
574
646
  ) -> Optional[str]:
647
+ """
648
+ Check if a Confluence page exists with the given title.
649
+
650
+ :param title: Page title. Pages in the same Confluence space must have a unique title.
651
+ :param space_key: Identifies the Confluence space.
652
+ """
653
+
654
+ space_id = self.get_space_id(space_id=space_id, space_key=space_key)
575
655
  path = "/pages"
576
- coalesced_space_key = space_key or self.space_key
577
656
  query = {"title": title}
578
- if coalesced_space_key is not None:
579
- query["space-id"] = self.space_key_to_id(coalesced_space_key)
657
+ if space_id is not None:
658
+ query["space-id"] = space_id
580
659
 
581
660
  LOGGER.info("Checking if page exists with title: %s", title)
582
661
 
@@ -595,14 +674,20 @@ class ConfluenceSession:
595
674
  else:
596
675
  return None
597
676
 
598
- def get_or_create_page(
599
- self, title: str, parent_id: str, *, space_key: Optional[str] = None
600
- ) -> ConfluencePage:
601
- page_id = self.page_exists(title)
677
+ def get_or_create_page(self, title: str, parent_id: str) -> ConfluencePage:
678
+ """
679
+ Find a page with the given title, or create a new page if no such page exists.
680
+
681
+ :param title: Page title. Pages in the same Confluence space must have a unique title.
682
+ :param parent_id: Identifies the parent page for a new child page.
683
+ """
684
+
685
+ parent_page = self.get_page_metadata(parent_id)
686
+ page_id = self.page_exists(title, space_id=parent_page.space_id)
602
687
 
603
688
  if page_id is not None:
604
689
  LOGGER.debug("Retrieving existing page: %s", page_id)
605
690
  return self.get_page(page_id)
606
691
  else:
607
692
  LOGGER.debug("Creating new page with title: %s", title)
608
- return self.create_page(parent_id, title, "", space_key=space_key)
693
+ return self.create_page(parent_id, title, "")