docspan 0.1.0__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.
- docspan/__init__.py +3 -0
- docspan/__main__.py +0 -0
- docspan/backends/__init__.py +19 -0
- docspan/backends/base.py +85 -0
- docspan/backends/confluence/__init__.py +0 -0
- docspan/backends/confluence/adf/__init__.py +14 -0
- docspan/backends/confluence/adf/comparator.py +427 -0
- docspan/backends/confluence/adf/converter.py +119 -0
- docspan/backends/confluence/adf/converters.py +1449 -0
- docspan/backends/confluence/adf/interfaces.py +191 -0
- docspan/backends/confluence/adf/nodes.py +2085 -0
- docspan/backends/confluence/adf/parser.py +400 -0
- docspan/backends/confluence/adf/validators.py +161 -0
- docspan/backends/confluence/adf/visitors.py +495 -0
- docspan/backends/confluence/backend.py +227 -0
- docspan/backends/confluence/client.py +44 -0
- docspan/backends/confluence/config/__init__.py +21 -0
- docspan/backends/confluence/config/loader.py +107 -0
- docspan/backends/confluence/config/models.py +167 -0
- docspan/backends/confluence/config/validation.py +297 -0
- docspan/backends/confluence/markdown/__init__.py +22 -0
- docspan/backends/confluence/markdown/ast.py +819 -0
- docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
- docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
- docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
- docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
- docspan/backends/confluence/markdown/inline_parser.py +495 -0
- docspan/backends/confluence/markdown/parser.py +1006 -0
- docspan/backends/confluence/models/__init__.py +18 -0
- docspan/backends/confluence/models/markdown_file.py +402 -0
- docspan/backends/confluence/models/page.py +212 -0
- docspan/backends/confluence/models/path_utils.py +34 -0
- docspan/backends/confluence/models/results.py +28 -0
- docspan/backends/confluence/models/sync_status.py +382 -0
- docspan/backends/confluence/services/__init__.py +0 -0
- docspan/backends/confluence/services/confluence/__init__.py +40 -0
- docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
- docspan/backends/confluence/services/confluence/base_client.py +420 -0
- docspan/backends/confluence/services/confluence/client.py +376 -0
- docspan/backends/confluence/services/confluence/comment_client.py +682 -0
- docspan/backends/confluence/services/confluence/crawler.py +587 -0
- docspan/backends/confluence/services/confluence/label_client.py +130 -0
- docspan/backends/confluence/services/confluence/page_client.py +1288 -0
- docspan/backends/confluence/services/confluence/space_client.py +179 -0
- docspan/backends/confluence/services/confluence/url_parser.py +106 -0
- docspan/backends/google_docs/__init__.py +0 -0
- docspan/backends/google_docs/auth.py +143 -0
- docspan/backends/google_docs/backend.py +140 -0
- docspan/backends/google_docs/client.py +665 -0
- docspan/backends/google_docs/converter.py +471 -0
- docspan/backends/google_docs/docs_request_builder.py +232 -0
- docspan/backends/google_docs/docs_structure_parser.py +120 -0
- docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
- docspan/cli/__init__.py +0 -0
- docspan/cli/main.py +408 -0
- docspan/config.py +62 -0
- docspan/core/__init__.py +49 -0
- docspan/core/merge.py +30 -0
- docspan/core/orchestrator.py +332 -0
- docspan/core/paths.py +8 -0
- docspan/core/state.py +53 -0
- docspan-0.1.0.dist-info/METADATA +273 -0
- docspan-0.1.0.dist-info/RECORD +65 -0
- docspan-0.1.0.dist-info/WHEEL +4 -0
- docspan-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models module.
|
|
3
|
+
|
|
4
|
+
This module provides data models/DTOs for the markdown-confluence package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from docspan.backends.confluence.models.markdown_file import MarkdownFile
|
|
8
|
+
from docspan.backends.confluence.models.page import ConfluencePage
|
|
9
|
+
from docspan.backends.confluence.models.results import PublishResult
|
|
10
|
+
from docspan.backends.confluence.models.sync_status import FileSyncRecord, SyncStatusTracker
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"MarkdownFile",
|
|
14
|
+
"ConfluencePage",
|
|
15
|
+
"PublishResult",
|
|
16
|
+
"FileSyncRecord",
|
|
17
|
+
"SyncStatusTracker",
|
|
18
|
+
]
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Models for Markdown files and their contents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Set
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Known frontmatter keys that markdown_confluence understands
|
|
14
|
+
KNOWN_CONNIE_KEYS: Set[str] = {
|
|
15
|
+
"connie-title", # Page title override
|
|
16
|
+
"connie-page-id", # Confluence page ID
|
|
17
|
+
"connie-parent-id", # Parent page ID (legacy)
|
|
18
|
+
"connie-parent-page-id", # Parent page ID (preferred)
|
|
19
|
+
"connie-publish", # Whether to publish this file
|
|
20
|
+
"connie-visibility", # Page visibility (private/public)
|
|
21
|
+
"connie-last-remote-version", # Last known remote version
|
|
22
|
+
"connie-last-sync-timestamp", # Last sync timestamp
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Common typos/mistakes mapped to the correct key
|
|
26
|
+
CONNIE_KEY_SUGGESTIONS: Dict[str, str] = {
|
|
27
|
+
"connie-page-title": "connie-title",
|
|
28
|
+
"connie-pagetitle": "connie-title",
|
|
29
|
+
"connie-name": "connie-title",
|
|
30
|
+
"connie-id": "connie-page-id",
|
|
31
|
+
"connie-pageid": "connie-page-id",
|
|
32
|
+
"connie-parent": "connie-parent-id",
|
|
33
|
+
"connie-parentid": "connie-parent-id",
|
|
34
|
+
"connie-parent-page": "connie-parent-id",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class MarkdownFile:
|
|
40
|
+
"""
|
|
41
|
+
Represents a Markdown file with its content and metadata.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
path: Path to the file
|
|
45
|
+
content: Raw content of the file
|
|
46
|
+
frontmatter: Extracted frontmatter as a dictionary
|
|
47
|
+
title: Title for the page (from filename or frontmatter)
|
|
48
|
+
title_from_h1: Whether the title was extracted from a first-level heading
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
path: Path
|
|
52
|
+
content: str
|
|
53
|
+
frontmatter: Dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
title: Optional[str] = None
|
|
55
|
+
title_from_h1: bool = False
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_file(cls, file_path: Path) -> "MarkdownFile":
|
|
59
|
+
"""
|
|
60
|
+
Create a MarkdownFile instance from a file.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
file_path: Path to the Markdown file
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
MarkdownFile instance
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
FileNotFoundError: If the file doesn't exist
|
|
70
|
+
UnicodeDecodeError: If the file can't be read as UTF-8
|
|
71
|
+
"""
|
|
72
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
73
|
+
content = f.read()
|
|
74
|
+
|
|
75
|
+
instance = cls(path=file_path, content=content)
|
|
76
|
+
instance.extract_frontmatter()
|
|
77
|
+
instance.validate_frontmatter()
|
|
78
|
+
instance.determine_title()
|
|
79
|
+
|
|
80
|
+
return instance
|
|
81
|
+
|
|
82
|
+
def extract_frontmatter(self) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Extract YAML frontmatter from content and update the instance.
|
|
85
|
+
"""
|
|
86
|
+
frontmatter_pattern = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
87
|
+
match = frontmatter_pattern.match(self.content)
|
|
88
|
+
|
|
89
|
+
if match:
|
|
90
|
+
try:
|
|
91
|
+
import yaml
|
|
92
|
+
|
|
93
|
+
yaml_content = match.group(1)
|
|
94
|
+
self.frontmatter = yaml.safe_load(yaml_content) or {}
|
|
95
|
+
|
|
96
|
+
# Remove frontmatter from content
|
|
97
|
+
self.content = self.content[match.end() :]
|
|
98
|
+
except ImportError:
|
|
99
|
+
# If pyyaml is not available, try simple key-value parsing
|
|
100
|
+
self._parse_frontmatter_simple(match.group(1))
|
|
101
|
+
self.content = self.content[match.end() :]
|
|
102
|
+
except Exception:
|
|
103
|
+
logger.warning("Failed to parse frontmatter; keeping raw content", exc_info=True)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _levenshtein_distance(s1: str, s2: str) -> int:
|
|
107
|
+
"""
|
|
108
|
+
Calculate the Levenshtein distance between two strings.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
s1: First string
|
|
112
|
+
s2: Second string
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Edit distance between the strings
|
|
116
|
+
"""
|
|
117
|
+
if len(s1) < len(s2):
|
|
118
|
+
return MarkdownFile._levenshtein_distance(s2, s1)
|
|
119
|
+
|
|
120
|
+
if len(s2) == 0:
|
|
121
|
+
return len(s1)
|
|
122
|
+
|
|
123
|
+
previous_row = range(len(s2) + 1)
|
|
124
|
+
for i, c1 in enumerate(s1):
|
|
125
|
+
current_row = [i + 1]
|
|
126
|
+
for j, c2 in enumerate(s2):
|
|
127
|
+
# Cost is 0 if characters match, 1 otherwise
|
|
128
|
+
insertions = previous_row[j + 1] + 1
|
|
129
|
+
deletions = current_row[j] + 1
|
|
130
|
+
substitutions = previous_row[j] + (c1 != c2)
|
|
131
|
+
current_row.append(min(insertions, deletions, substitutions))
|
|
132
|
+
previous_row = current_row
|
|
133
|
+
|
|
134
|
+
return previous_row[-1]
|
|
135
|
+
|
|
136
|
+
def _find_closest_key(self, unknown_key: str, max_distance: int = 5) -> Optional[str]:
|
|
137
|
+
"""
|
|
138
|
+
Find the closest known key to an unknown key using Levenshtein distance.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
unknown_key: The unrecognized frontmatter key
|
|
142
|
+
max_distance: Maximum edit distance to consider a suggestion
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The closest known key if within max_distance, None otherwise
|
|
146
|
+
"""
|
|
147
|
+
# First check explicit suggestions mapping
|
|
148
|
+
if unknown_key in CONNIE_KEY_SUGGESTIONS:
|
|
149
|
+
return CONNIE_KEY_SUGGESTIONS[unknown_key]
|
|
150
|
+
|
|
151
|
+
# Otherwise, find closest by Levenshtein distance
|
|
152
|
+
best_match = None
|
|
153
|
+
best_distance = max_distance + 1
|
|
154
|
+
|
|
155
|
+
for known_key in KNOWN_CONNIE_KEYS:
|
|
156
|
+
distance = self._levenshtein_distance(unknown_key, known_key)
|
|
157
|
+
if distance < best_distance:
|
|
158
|
+
best_distance = distance
|
|
159
|
+
best_match = known_key
|
|
160
|
+
|
|
161
|
+
return best_match if best_distance <= max_distance else None
|
|
162
|
+
|
|
163
|
+
def validate_frontmatter(self) -> List[str]:
|
|
164
|
+
"""
|
|
165
|
+
Validate frontmatter keys and warn about unrecognized connie-* keys.
|
|
166
|
+
|
|
167
|
+
Uses Levenshtein distance to suggest the closest known key for typos.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of warning messages for unrecognized keys
|
|
171
|
+
"""
|
|
172
|
+
warnings = []
|
|
173
|
+
|
|
174
|
+
for key in self.frontmatter.keys():
|
|
175
|
+
# Only validate keys that start with "connie-"
|
|
176
|
+
if not key.startswith("connie-"):
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if key in KNOWN_CONNIE_KEYS:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# Find the closest known key
|
|
183
|
+
suggestion = self._find_closest_key(key)
|
|
184
|
+
|
|
185
|
+
if suggestion:
|
|
186
|
+
msg = (
|
|
187
|
+
f"Unrecognized frontmatter key '{key}' in {self.path}. "
|
|
188
|
+
f"Did you mean '{suggestion}'?"
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
msg = (
|
|
192
|
+
f"Unrecognized frontmatter key '{key}' in {self.path}. "
|
|
193
|
+
f"Known keys are: {', '.join(sorted(KNOWN_CONNIE_KEYS))}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
logger.warning(msg)
|
|
197
|
+
warnings.append(msg)
|
|
198
|
+
|
|
199
|
+
return warnings
|
|
200
|
+
|
|
201
|
+
def _parse_frontmatter_simple(self, yaml_content: str) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Simple YAML parser for frontmatter (no pyyaml dependency).
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
yaml_content: YAML content to parse
|
|
207
|
+
"""
|
|
208
|
+
for line in yaml_content.split("\n"):
|
|
209
|
+
if ":" in line:
|
|
210
|
+
key, value = line.split(":", 1)
|
|
211
|
+
key = key.strip()
|
|
212
|
+
value = value.strip()
|
|
213
|
+
|
|
214
|
+
# Handle quoted values
|
|
215
|
+
if value.startswith('"') and value.endswith('"'):
|
|
216
|
+
value = value[1:-1]
|
|
217
|
+
elif value.startswith("'") and value.endswith("'"):
|
|
218
|
+
value = value[1:-1]
|
|
219
|
+
|
|
220
|
+
self.frontmatter[key] = value
|
|
221
|
+
|
|
222
|
+
def determine_title(self) -> None:
|
|
223
|
+
"""
|
|
224
|
+
Determine the title for the page.
|
|
225
|
+
|
|
226
|
+
Sets title_from_h1=True if the title is extracted from a first-level heading,
|
|
227
|
+
which allows the ADF converter to skip the duplicate heading in content.
|
|
228
|
+
"""
|
|
229
|
+
# First, try to get the title from frontmatter
|
|
230
|
+
title = self.frontmatter.get("connie-title")
|
|
231
|
+
if title:
|
|
232
|
+
self.title = title
|
|
233
|
+
self.title_from_h1 = False
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
# Otherwise, extract the first header from the content
|
|
237
|
+
header_pattern = re.compile(r"^#\s+(.+?)$", re.MULTILINE)
|
|
238
|
+
match = header_pattern.search(self.content)
|
|
239
|
+
if match:
|
|
240
|
+
self.title = match.group(1)
|
|
241
|
+
self.title_from_h1 = True # Mark that title came from H1
|
|
242
|
+
else:
|
|
243
|
+
# If no header found, use the filename
|
|
244
|
+
self.title = self.path.stem
|
|
245
|
+
self.title_from_h1 = False
|
|
246
|
+
|
|
247
|
+
def save(self) -> None:
|
|
248
|
+
"""
|
|
249
|
+
Save changes back to the file.
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
# Generate frontmatter
|
|
253
|
+
import yaml
|
|
254
|
+
|
|
255
|
+
frontmatter_str = yaml.dump(self.frontmatter, default_flow_style=False)
|
|
256
|
+
output = f"---\n{frontmatter_str}---\n\n{self.content}"
|
|
257
|
+
except ImportError:
|
|
258
|
+
# Fallback if pyyaml is not available
|
|
259
|
+
frontmatter_lines = []
|
|
260
|
+
for key, value in self.frontmatter.items():
|
|
261
|
+
if isinstance(value, str) and " " in value:
|
|
262
|
+
frontmatter_lines.append(f"{key}: \"{value}\"")
|
|
263
|
+
else:
|
|
264
|
+
frontmatter_lines.append(f"{key}: {value}")
|
|
265
|
+
frontmatter_str = "\n".join(frontmatter_lines)
|
|
266
|
+
output = f"---\n{frontmatter_str}\n---\n\n{self.content}"
|
|
267
|
+
|
|
268
|
+
with open(self.path, "w", encoding="utf-8") as f:
|
|
269
|
+
f.write(output)
|
|
270
|
+
|
|
271
|
+
def get_confluence_page_id(self) -> Optional[str]:
|
|
272
|
+
"""
|
|
273
|
+
Get Confluence page ID from frontmatter.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Page ID or None if not found
|
|
277
|
+
"""
|
|
278
|
+
return self.frontmatter.get("connie-page-id")
|
|
279
|
+
|
|
280
|
+
def get_parent_id(self) -> Optional[str]:
|
|
281
|
+
"""
|
|
282
|
+
Get parent page ID from frontmatter.
|
|
283
|
+
|
|
284
|
+
Checks both 'connie-parent-page-id' (preferred) and 'connie-parent-id' (legacy)
|
|
285
|
+
for backward compatibility.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Parent page ID or None if not found
|
|
289
|
+
"""
|
|
290
|
+
# Check preferred field name first
|
|
291
|
+
parent_id = self.frontmatter.get("connie-parent-page-id")
|
|
292
|
+
if parent_id:
|
|
293
|
+
return parent_id
|
|
294
|
+
# Fall back to legacy field name
|
|
295
|
+
return self.frontmatter.get("connie-parent-id")
|
|
296
|
+
|
|
297
|
+
def remove_confluence_page_id(self) -> None:
|
|
298
|
+
"""
|
|
299
|
+
Remove Confluence page ID from frontmatter.
|
|
300
|
+
"""
|
|
301
|
+
if "connie-page-id" in self.frontmatter:
|
|
302
|
+
del self.frontmatter["connie-page-id"]
|
|
303
|
+
|
|
304
|
+
def get_tags(self) -> List[str]:
|
|
305
|
+
"""
|
|
306
|
+
Get tags from frontmatter.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
List of tags
|
|
310
|
+
"""
|
|
311
|
+
tags = self.frontmatter.get("tags", [])
|
|
312
|
+
if isinstance(tags, str):
|
|
313
|
+
return [tag.strip() for tag in tags.split(",")]
|
|
314
|
+
elif isinstance(tags, list):
|
|
315
|
+
return tags
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
def should_publish(self) -> bool:
|
|
319
|
+
"""
|
|
320
|
+
Check if this file should be published based on frontmatter.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
True if file should be published, False otherwise
|
|
324
|
+
"""
|
|
325
|
+
return self.frontmatter.get("connie-publish", True)
|
|
326
|
+
|
|
327
|
+
# Sync metadata methods
|
|
328
|
+
|
|
329
|
+
def get_remote_version(self) -> Optional[int]:
|
|
330
|
+
"""
|
|
331
|
+
Get last known remote version from frontmatter.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Remote version number or None if not tracked
|
|
335
|
+
"""
|
|
336
|
+
version = self.frontmatter.get("connie-last-remote-version")
|
|
337
|
+
return int(version) if version is not None else None
|
|
338
|
+
|
|
339
|
+
def set_remote_version(self, version: int) -> None:
|
|
340
|
+
"""
|
|
341
|
+
Set last known remote version in frontmatter.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
version: Remote version number
|
|
345
|
+
"""
|
|
346
|
+
self.frontmatter["connie-last-remote-version"] = version
|
|
347
|
+
|
|
348
|
+
def get_last_sync_timestamp(self) -> Optional[str]:
|
|
349
|
+
"""
|
|
350
|
+
Get last sync timestamp from frontmatter.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
ISO 8601 timestamp or None if never synced
|
|
354
|
+
"""
|
|
355
|
+
return self.frontmatter.get("connie-last-sync-timestamp")
|
|
356
|
+
|
|
357
|
+
def set_last_sync_timestamp(self, timestamp: str) -> None:
|
|
358
|
+
"""
|
|
359
|
+
Set last sync timestamp in frontmatter.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
timestamp: ISO 8601 timestamp
|
|
363
|
+
"""
|
|
364
|
+
self.frontmatter["connie-last-sync-timestamp"] = timestamp
|
|
365
|
+
|
|
366
|
+
def get_local_modified_timestamp(self) -> Optional[float]:
|
|
367
|
+
"""
|
|
368
|
+
Get file's last modification timestamp.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Unix timestamp of last modification
|
|
372
|
+
"""
|
|
373
|
+
if self.path.exists():
|
|
374
|
+
return self.path.stat().st_mtime
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
def was_modified_since_sync(self) -> bool:
|
|
378
|
+
"""
|
|
379
|
+
Check if local file was modified since last sync.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
True if file was modified after last sync, False otherwise
|
|
383
|
+
"""
|
|
384
|
+
last_sync = self.get_last_sync_timestamp()
|
|
385
|
+
if not last_sync:
|
|
386
|
+
# Never synced, consider it modified
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
from datetime import datetime
|
|
391
|
+
last_sync_time = datetime.fromisoformat(last_sync.replace('Z', '+00:00'))
|
|
392
|
+
file_mtime = self.get_local_modified_timestamp()
|
|
393
|
+
|
|
394
|
+
if file_mtime is None:
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
file_time = datetime.fromtimestamp(file_mtime)
|
|
398
|
+
# Allow 2 second grace period for file system timestamp precision
|
|
399
|
+
return file_time.timestamp() > last_sync_time.timestamp() + 2
|
|
400
|
+
except Exception:
|
|
401
|
+
logger.warning("Could not determine file modification time; assuming modified", exc_info=True)
|
|
402
|
+
return True
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Models for Confluence pages.
|
|
3
|
+
|
|
4
|
+
This module enforces ADF (Atlassian Document Format) as the exclusive format
|
|
5
|
+
for Confluence Cloud pages, with no fallback to legacy storage format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Dict, List, Optional, Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ConfluencePage:
|
|
15
|
+
"""
|
|
16
|
+
Represents a Confluence page using ADF (Atlassian Document Format).
|
|
17
|
+
|
|
18
|
+
This class enforces ADF as the exclusive format for Confluence Cloud.
|
|
19
|
+
Storage format is not supported and will raise errors.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
title: Page title
|
|
23
|
+
content: Page content in ADF format (must be dict)
|
|
24
|
+
space_key: Confluence space key
|
|
25
|
+
parent_id: ID of the parent page
|
|
26
|
+
id: Confluence page ID (for updating existing pages)
|
|
27
|
+
version: Current page version (for updates)
|
|
28
|
+
labels: List of labels to apply to the page
|
|
29
|
+
force_update: Flag to force an update even if content appears unchanged
|
|
30
|
+
restrictions: List of restriction objects defining read/update permissions
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
title: str
|
|
34
|
+
content: Union[str, Dict[str, Any]] # Should be ADF dict; string will raise error
|
|
35
|
+
parent_id: str
|
|
36
|
+
space_key: Optional[str] = None # Can be derived from parent_id if not specified
|
|
37
|
+
id: Optional[str] = None
|
|
38
|
+
version: Optional[int] = None
|
|
39
|
+
labels: List[str] = field(default_factory=list)
|
|
40
|
+
force_update: bool = False
|
|
41
|
+
restrictions: Optional[List[Dict[str, Any]]] = None
|
|
42
|
+
|
|
43
|
+
def to_api_data(self, for_update: bool = False) -> Dict[str, Any]:
|
|
44
|
+
"""
|
|
45
|
+
Convert to data suitable for Confluence API using ADF format.
|
|
46
|
+
|
|
47
|
+
This method enforces ADF (Atlassian Document Format) as the exclusive format.
|
|
48
|
+
Storage format is not supported and will raise errors.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
for_update: Whether this is for an update operation
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Dictionary with API data in ADF format
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If required fields are missing for update
|
|
58
|
+
UnsupportedADFFeatureError: If content contains unsupported features
|
|
59
|
+
ADFConversionError: If content cannot be converted to valid ADF
|
|
60
|
+
"""
|
|
61
|
+
if for_update:
|
|
62
|
+
if not self.id:
|
|
63
|
+
raise ValueError("Page ID is required for update")
|
|
64
|
+
if self.version is None:
|
|
65
|
+
raise ValueError("Page version is required for update")
|
|
66
|
+
|
|
67
|
+
data = {
|
|
68
|
+
"id": self.id,
|
|
69
|
+
"type": "page",
|
|
70
|
+
"title": self.title,
|
|
71
|
+
"version": {"number": self.version + 1},
|
|
72
|
+
}
|
|
73
|
+
else:
|
|
74
|
+
data = {
|
|
75
|
+
"type": "page",
|
|
76
|
+
"title": self.title,
|
|
77
|
+
"ancestors": [{"id": self.parent_id}] if self.parent_id else [],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Add space key if provided
|
|
81
|
+
if self.space_key:
|
|
82
|
+
data["space"] = {"key": self.space_key}
|
|
83
|
+
|
|
84
|
+
# Add restrictions if provided (works for both create and update)
|
|
85
|
+
if self.restrictions:
|
|
86
|
+
data["restrictions"] = self.restrictions
|
|
87
|
+
|
|
88
|
+
# Handle content - always use ADF format
|
|
89
|
+
if isinstance(self.content, dict):
|
|
90
|
+
# Content is already ADF document
|
|
91
|
+
self._validate_adf(self.content)
|
|
92
|
+
data["body"] = {"atlas_doc_format": self.content}
|
|
93
|
+
elif isinstance(self.content, str) and self.content.startswith("{") and self.content.endswith("}"):
|
|
94
|
+
# Content looks like JSON string - parse and validate
|
|
95
|
+
try:
|
|
96
|
+
adf_content = json.loads(self.content)
|
|
97
|
+
self._validate_adf(adf_content)
|
|
98
|
+
data["body"] = {"atlas_doc_format": adf_content}
|
|
99
|
+
except json.JSONDecodeError as e:
|
|
100
|
+
from docspan.backends.confluence.services.confluence.base_client import (
|
|
101
|
+
ADFConversionError,
|
|
102
|
+
)
|
|
103
|
+
raise ADFConversionError(
|
|
104
|
+
f"Failed to parse JSON content: {e}",
|
|
105
|
+
markdown_content=self.content[:200]
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
# String content is not supported - must be ADF dict
|
|
109
|
+
from docspan.backends.confluence.services.confluence.base_client import (
|
|
110
|
+
ADFConversionError,
|
|
111
|
+
)
|
|
112
|
+
raise ADFConversionError(
|
|
113
|
+
"Content must be ADF dictionary format. String/storage format is not supported.",
|
|
114
|
+
markdown_content=str(self.content)[:200] if self.content else None
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
def _validate_adf(self, adf_content: Dict[str, Any]) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Validate ADF content for unsupported features.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
adf_content: ADF content to validate
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
UnsupportedADFFeatureError: If content contains unsupported features
|
|
128
|
+
InvalidADFError: If ADF structure is invalid
|
|
129
|
+
"""
|
|
130
|
+
from docspan.backends.confluence.services.confluence.base_client import (
|
|
131
|
+
InvalidADFError,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Check basic structure
|
|
135
|
+
if not isinstance(adf_content, dict):
|
|
136
|
+
raise InvalidADFError(
|
|
137
|
+
"ADF content must be a dictionary",
|
|
138
|
+
adf_content=adf_content
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Check for storage_format_html (indicates legacy format)
|
|
142
|
+
# NOTE: Disabled this validation because storage_format_html is legitimately
|
|
143
|
+
# used by the Mermaid plugin to embed rendered diagrams.
|
|
144
|
+
# The original validation was too strict and rejected valid ADF content.
|
|
145
|
+
# if self._has_storage_format_html(adf_content):
|
|
146
|
+
# raise UnsupportedADFFeatureError(
|
|
147
|
+
# "Content contains 'storage_format_html' attribute. "
|
|
148
|
+
# "This indicates legacy storage format which is no longer supported. "
|
|
149
|
+
# "Content must be converted to pure ADF format.",
|
|
150
|
+
# feature_name="storage_format_html"
|
|
151
|
+
# )
|
|
152
|
+
|
|
153
|
+
# Validate node types are supported
|
|
154
|
+
self._validate_node_types(adf_content)
|
|
155
|
+
|
|
156
|
+
def _has_storage_format_html(self, adf_content: Dict[str, Any]) -> bool:
|
|
157
|
+
"""
|
|
158
|
+
Check if any node in the ADF content has storage_format_html.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
adf_content: ADF content to check
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
True if any node has storage_format_html, False otherwise
|
|
165
|
+
"""
|
|
166
|
+
# Check current node
|
|
167
|
+
if "attrs" in adf_content and "storage_format_html" in adf_content["attrs"]:
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
# Check content nodes recursively
|
|
171
|
+
if "content" in adf_content and isinstance(adf_content["content"], list):
|
|
172
|
+
for node in adf_content["content"]:
|
|
173
|
+
if isinstance(node, dict) and self._has_storage_format_html(node):
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
def _validate_node_types(self, node: Dict[str, Any]) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Recursively validate that all node types are supported.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
node: ADF node to validate
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
UnsupportedADFFeatureError: If node contains unsupported types
|
|
187
|
+
"""
|
|
188
|
+
from docspan.backends.confluence.services.confluence.base_client import (
|
|
189
|
+
UnsupportedADFFeatureError,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
node_type = node.get("type")
|
|
193
|
+
|
|
194
|
+
# List of known unsupported features (expand as we discover them)
|
|
195
|
+
# Note: This is intentionally minimal - we discover unsupported features through errors
|
|
196
|
+
unsupported_types = {
|
|
197
|
+
# Add types here as we discover them
|
|
198
|
+
# Example: "customPanel", "unknownExtension"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if node_type in unsupported_types:
|
|
202
|
+
raise UnsupportedADFFeatureError(
|
|
203
|
+
f"ADF node type '{node_type}' is not yet supported. "
|
|
204
|
+
f"Please implement ADF conversion for this type.",
|
|
205
|
+
feature_name=node_type
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Recursively check children
|
|
209
|
+
if "content" in node and isinstance(node["content"], list):
|
|
210
|
+
for child in node["content"]:
|
|
211
|
+
if isinstance(child, dict):
|
|
212
|
+
self._validate_node_types(child)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path utilities for handling relative paths.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def safe_relative_path(path: Union[str, Path], base_path: Union[str, Path]) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Safely get a relative path without raising ValueError for paths outside the base.
|
|
13
|
+
|
|
14
|
+
This function handles cases where the target path is not a subpath of the base path,
|
|
15
|
+
which would normally cause Path.relative_to() to raise a ValueError.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
path: Path to convert to relative
|
|
19
|
+
base_path: Base path to make relative to
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
String representation of the relative path or the original path if outside base
|
|
23
|
+
"""
|
|
24
|
+
path_obj = Path(path).resolve()
|
|
25
|
+
base_path_obj = Path(base_path).resolve()
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
# Try standard relative_to
|
|
29
|
+
return str(path_obj.relative_to(base_path_obj))
|
|
30
|
+
except ValueError:
|
|
31
|
+
# Path is outside base directory
|
|
32
|
+
# Use os.path.relpath which handles paths outside the base
|
|
33
|
+
rel_path = os.path.relpath(path_obj, base_path_obj)
|
|
34
|
+
return rel_path
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Result models for operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PublishResult:
|
|
11
|
+
"""
|
|
12
|
+
Result of a publish operation.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
success: Whether the operation was successful
|
|
16
|
+
page_id: ID of the published page
|
|
17
|
+
page_url: URL of the published page
|
|
18
|
+
errors: List of error messages
|
|
19
|
+
deferred: Whether the operation was deferred for later processing
|
|
20
|
+
requires_parent: ID of the parent page that needs to be created first
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
success: bool
|
|
24
|
+
page_id: Optional[str] = None
|
|
25
|
+
page_url: Optional[str] = None
|
|
26
|
+
errors: List[str] = field(default_factory=list)
|
|
27
|
+
deferred: bool = False
|
|
28
|
+
requires_parent: Optional[str] = None
|