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.
md2conf/application.py CHANGED
@@ -6,8 +6,8 @@ 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
11
  from pathlib import Path
12
12
  from typing import Optional
13
13
 
@@ -15,155 +15,43 @@ from .api import ConfluencePage, ConfluenceSession
15
15
  from .converter import (
16
16
  ConfluenceDocument,
17
17
  ConfluenceDocumentOptions,
18
- ConfluencePageMetadata,
19
- ConfluenceQualifiedID,
18
+ ConfluencePageID,
20
19
  attachment_name,
21
20
  extract_frontmatter_title,
22
21
  extract_qualified_id,
23
- read_qualified_id,
24
22
  )
25
- from .matcher import Matcher, MatcherOptions
23
+ from .metadata import ConfluencePageMetadata
24
+ from .processor import Converter, Processor, ProcessorFactory
25
+ from .properties import PageError
26
26
 
27
27
  LOGGER = logging.getLogger(__name__)
28
28
 
29
29
 
30
- class Application:
31
- "The entry point for Markdown to Confluence conversion."
30
+ class SynchronizingProcessor(Processor):
31
+ """
32
+ Synchronizes a single Markdown page or a directory of Markdown pages with Confluence.
33
+ """
32
34
 
33
35
  api: ConfluenceSession
34
- options: ConfluenceDocumentOptions
35
36
 
36
37
  def __init__(
37
- self, api: ConfluenceSession, options: ConfluenceDocumentOptions
38
- ) -> None:
39
- self.api = api
40
- self.options = options
41
-
42
- def synchronize(self, path: Path) -> None:
43
- "Synchronizes a single Markdown page or a directory of Markdown pages."
44
-
45
- path = path.resolve(True)
46
- if path.is_dir():
47
- self.synchronize_directory(path)
48
- elif path.is_file():
49
- self.synchronize_page(path)
50
- else:
51
- raise ValueError(f"expected: valid file or directory path; got: {path}")
52
-
53
- def synchronize_page(
54
- self, page_path: Path, root_dir: Optional[Path] = None
55
- ) -> None:
56
- "Synchronizes a single Markdown page with Confluence."
57
-
58
- page_path = page_path.resolve(True)
59
- if root_dir is None:
60
- root_dir = page_path.parent
61
- else:
62
- root_dir = root_dir.resolve(True)
63
-
64
- self._synchronize_page(page_path, root_dir, {})
65
-
66
- def synchronize_directory(
67
- self, local_dir: Path, root_dir: Optional[Path] = None
68
- ) -> None:
69
- "Synchronizes a directory of Markdown pages with Confluence."
70
-
71
- local_dir = local_dir.resolve(True)
72
- if root_dir is None:
73
- root_dir = local_dir
74
- else:
75
- root_dir = root_dir.resolve(True)
76
-
77
- LOGGER.info("Synchronizing directory: %s", local_dir)
78
-
79
- # Step 1: build index of all page metadata
80
- page_metadata: dict[Path, ConfluencePageMetadata] = {}
81
- root_id = (
82
- ConfluenceQualifiedID(self.options.root_page_id, self.api.space_key)
83
- if self.options.root_page_id
84
- else None
85
- )
86
- self._index_directory(local_dir, root_id, page_metadata)
87
- LOGGER.info("Indexed %d page(s)", len(page_metadata))
88
-
89
- # Step 2: convert each page
90
- for page_path in page_metadata.keys():
91
- self._synchronize_page(page_path, root_dir, page_metadata)
92
-
93
- def _synchronize_page(
94
- self,
95
- page_path: Path,
96
- root_dir: Path,
97
- page_metadata: dict[Path, ConfluencePageMetadata],
38
+ self, api: ConfluenceSession, options: ConfluenceDocumentOptions, root_dir: Path
98
39
  ) -> None:
99
- base_path = page_path.parent
100
-
101
- LOGGER.info("Synchronizing page: %s", page_path)
102
- document = ConfluenceDocument(page_path, self.options, root_dir, page_metadata)
103
- self._update_document(document, base_path)
104
-
105
- def _index_directory(
106
- self,
107
- local_dir: Path,
108
- root_id: Optional[ConfluenceQualifiedID],
109
- page_metadata: dict[Path, ConfluencePageMetadata],
110
- ) -> None:
111
- "Indexes Markdown files in a directory recursively."
112
-
113
- LOGGER.info("Indexing directory: %s", local_dir)
114
-
115
- matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
116
-
117
- files: list[Path] = []
118
- directories: list[Path] = []
119
- for entry in os.scandir(local_dir):
120
- if matcher.is_excluded(entry.name, entry.is_dir()):
121
- continue
122
-
123
- if entry.is_file():
124
- files.append(Path(local_dir) / entry.name)
125
- elif entry.is_dir():
126
- directories.append(Path(local_dir) / entry.name)
127
-
128
- # make page act as parent node in Confluence
129
- parent_doc: Optional[Path] = None
130
- if (Path(local_dir) / "index.md") in files:
131
- parent_doc = Path(local_dir) / "index.md"
132
- elif (Path(local_dir) / "README.md") in files:
133
- parent_doc = Path(local_dir) / "README.md"
134
- elif (Path(local_dir) / f"{local_dir.name}.md") in files:
135
- parent_doc = Path(local_dir) / f"{local_dir.name}.md"
136
-
137
- if parent_doc is None and self.options.keep_hierarchy:
138
- parent_doc = Path(local_dir) / "index.md"
139
-
140
- # create a blank page in Confluence for the directory entry
141
- with open(parent_doc, "w"):
142
- pass
143
-
144
- if parent_doc is not None:
145
- files.remove(parent_doc)
146
-
147
- metadata = self._get_or_create_page(parent_doc, root_id)
148
- LOGGER.debug("Indexed parent %s with metadata: %s", parent_doc, metadata)
149
- page_metadata[parent_doc] = metadata
150
-
151
- parent_id = read_qualified_id(parent_doc) or root_id
152
- else:
153
- parent_id = root_id
40
+ """
41
+ Initializes a new processor instance.
154
42
 
155
- for doc in files:
156
- metadata = self._get_or_create_page(doc, parent_id)
157
- LOGGER.debug("Indexed %s with metadata: %s", doc, metadata)
158
- page_metadata[doc] = metadata
43
+ :param api: Holds information about an open session to a Confluence server.
44
+ :param options: Options that control the generated page content.
45
+ :param root_dir: File system directory that acts as topmost root node.
46
+ """
159
47
 
160
- for directory in directories:
161
- self._index_directory(directory, parent_id, page_metadata)
48
+ super().__init__(options, api.site, root_dir)
49
+ self.api = api
162
50
 
163
51
  def _get_or_create_page(
164
52
  self,
165
53
  absolute_path: Path,
166
- parent_id: Optional[ConfluenceQualifiedID],
54
+ parent_id: Optional[ConfluencePageID],
167
55
  *,
168
56
  title: Optional[str] = None,
169
57
  ) -> ConfluencePageMetadata:
@@ -173,54 +61,60 @@ class Application:
173
61
 
174
62
  # parse file
175
63
  with open(absolute_path, "r", encoding="utf-8") as f:
176
- document = f.read()
64
+ text = f.read()
177
65
 
178
- qualified_id, document = extract_qualified_id(document)
179
- frontmatter_title, _ = extract_frontmatter_title(document)
66
+ qualified_id, text = extract_qualified_id(text)
180
67
 
181
- if qualified_id is not None:
182
- confluence_page = self.api.get_page(qualified_id.page_id)
183
- else:
68
+ overwrite = False
69
+ if qualified_id is None:
70
+ # create new Confluence page
184
71
  if parent_id is None:
185
- raise ValueError(
72
+ raise PageError(
186
73
  f"expected: parent page ID for Markdown file with no linked Confluence page: {absolute_path}"
187
74
  )
188
75
 
189
- # assign title from frontmatter if present
190
- confluence_page = self._create_page(
191
- absolute_path, document, title or frontmatter_title, parent_id
192
- )
76
+ # assign title from front-matter if present
77
+ if title is None:
78
+ title, _ = extract_frontmatter_title(text)
79
+
80
+ # use file name (without extension) and path hash if no title is supplied
81
+ if title is None:
82
+ overwrite = True
83
+ relative_path = absolute_path.relative_to(self.root_dir)
84
+ hash = hashlib.md5(relative_path.as_posix().encode("utf-8"))
85
+ digest = "".join(f"{c:x}" for c in hash.digest())
86
+ title = f"{absolute_path.stem} [{digest}]"
87
+
88
+ confluence_page = self._create_page(absolute_path, text, title, parent_id)
89
+ else:
90
+ # look up existing Confluence page
91
+ confluence_page = self.api.get_page(qualified_id.page_id)
193
92
 
194
93
  space_key = (
195
94
  self.api.space_id_to_key(confluence_page.space_id)
196
95
  if confluence_page.space_id
197
- else self.api.space_key
96
+ else self.site.space_key
198
97
  )
199
98
 
200
99
  return ConfluencePageMetadata(
201
- domain=self.api.domain,
202
- base_path=self.api.base_path,
203
100
  page_id=confluence_page.id,
204
101
  space_key=space_key,
205
- title=confluence_page.title or "",
102
+ title=confluence_page.title,
103
+ overwrite=overwrite,
206
104
  )
207
105
 
208
106
  def _create_page(
209
107
  self,
210
108
  absolute_path: Path,
211
109
  document: str,
212
- title: Optional[str],
213
- parent_id: ConfluenceQualifiedID,
110
+ title: str,
111
+ parent_id: ConfluencePageID,
214
112
  ) -> ConfluencePage:
215
- "Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet."
216
-
217
- # use file name without extension if no title is supplied
218
- if title is None:
219
- title = absolute_path.stem
113
+ """
114
+ Creates a new Confluence page when Markdown file doesn't have an embedded page ID yet.
115
+ """
220
116
 
221
- confluence_page = self.api.get_or_create_page(
222
- title, parent_id.page_id, space_key=parent_id.space_key
223
- )
117
+ confluence_page = self.api.get_or_create_page(title, parent_id.page_id)
224
118
  self._update_markdown(
225
119
  absolute_path,
226
120
  document,
@@ -229,9 +123,14 @@ class Application:
229
123
  )
230
124
  return confluence_page
231
125
 
232
- def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
233
- "Saves a new version of a Confluence document."
126
+ def _save_document(self, document: ConfluenceDocument, path: Path) -> None:
127
+ """
128
+ Saves a new version of a Confluence document.
129
+
130
+ Invokes Confluence REST API to persist the new version.
131
+ """
234
132
 
133
+ base_path = path.parent
235
134
  for image in document.images:
236
135
  self.api.upload_attachment(
237
136
  document.id.page_id,
@@ -247,8 +146,12 @@ class Application:
247
146
  )
248
147
 
249
148
  content = document.xhtml()
149
+
150
+ # leave title as it is for existing pages, update title for pages with randomly assigned title
151
+ title = document.title if self.page_metadata[path].overwrite else None
152
+
250
153
  LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
251
- self.api.update_page(document.id.page_id, content, title=document.title)
154
+ self.api.update_page(document.id.page_id, content, title=title)
252
155
 
253
156
  def _update_markdown(
254
157
  self,
@@ -257,7 +160,9 @@ class Application:
257
160
  page_id: str,
258
161
  space_key: Optional[str],
259
162
  ) -> None:
260
- "Writes the Confluence page ID and space key at the beginning of the Markdown file."
163
+ """
164
+ Writes the Confluence page ID and space key at the beginning of the Markdown file.
165
+ """
261
166
 
262
167
  content: list[str] = []
263
168
 
@@ -277,3 +182,27 @@ class Application:
277
182
 
278
183
  with open(path, "w", encoding="utf-8") as file:
279
184
  file.write("\n".join(content))
185
+
186
+
187
+ class SynchronizingProcessorFactory(ProcessorFactory):
188
+ api: ConfluenceSession
189
+
190
+ def __init__(
191
+ self, api: ConfluenceSession, options: ConfluenceDocumentOptions
192
+ ) -> None:
193
+ super().__init__(options, api.site)
194
+ self.api = api
195
+
196
+ def create(self, root_dir: Path) -> Processor:
197
+ return SynchronizingProcessor(self.api, self.options, root_dir)
198
+
199
+
200
+ class Application(Converter):
201
+ """
202
+ The entry point for Markdown to Confluence conversion.
203
+ """
204
+
205
+ def __init__(
206
+ self, api: ConfluenceSession, options: ConfluenceDocumentOptions
207
+ ) -> None:
208
+ super().__init__(SynchronizingProcessorFactory(api, options))