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,382 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Models for tracking file sync status.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List, Optional, Set, Union
|
|
11
|
+
|
|
12
|
+
from docspan.backends.confluence.models.path_utils import safe_relative_path
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FileSyncRecord:
|
|
19
|
+
"""
|
|
20
|
+
Represents sync status for a single file.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
relative_path: Path relative to the root directory
|
|
24
|
+
page_id: Confluence page ID
|
|
25
|
+
title: Page title in Confluence
|
|
26
|
+
last_synced: Timestamp of last successful sync
|
|
27
|
+
last_modified: Last modification time of the file when synced
|
|
28
|
+
status: Current sync status (synced, modified, deleted, etc.)
|
|
29
|
+
history: List of previous paths if the file was renamed
|
|
30
|
+
"""
|
|
31
|
+
relative_path: str
|
|
32
|
+
page_id: str
|
|
33
|
+
title: str
|
|
34
|
+
last_synced: str
|
|
35
|
+
last_modified: float
|
|
36
|
+
status: str = "synced" # synced, modified, renamed, deleted
|
|
37
|
+
history: List[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_file(cls, file_path: Path, root_dir: Path, page_id: str, title: str) -> "FileSyncRecord":
|
|
41
|
+
"""
|
|
42
|
+
Create a sync record from a file.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
file_path: Path to the file
|
|
46
|
+
root_dir: Root directory for calculating relative path
|
|
47
|
+
page_id: Confluence page ID
|
|
48
|
+
title: Page title
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
New FileSyncRecord instance
|
|
52
|
+
"""
|
|
53
|
+
now = datetime.now().isoformat()
|
|
54
|
+
last_modified = file_path.stat().st_mtime
|
|
55
|
+
relative_path = safe_relative_path(file_path, root_dir)
|
|
56
|
+
|
|
57
|
+
return cls(
|
|
58
|
+
relative_path=relative_path,
|
|
59
|
+
page_id=page_id,
|
|
60
|
+
title=title,
|
|
61
|
+
last_synced=now,
|
|
62
|
+
last_modified=last_modified,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def mark_as_renamed(self, new_path: str) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Mark the file as renamed and update its path.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
new_path: New relative path
|
|
71
|
+
"""
|
|
72
|
+
# Add current path to history
|
|
73
|
+
if self.relative_path not in self.history:
|
|
74
|
+
self.history.append(self.relative_path)
|
|
75
|
+
|
|
76
|
+
# Update path and status
|
|
77
|
+
self.relative_path = new_path
|
|
78
|
+
self.status = "renamed"
|
|
79
|
+
|
|
80
|
+
def mark_as_deleted(self) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Mark the file as deleted.
|
|
83
|
+
"""
|
|
84
|
+
self.status = "deleted"
|
|
85
|
+
self.last_synced = datetime.now().isoformat()
|
|
86
|
+
|
|
87
|
+
def mark_as_synced(self, file_path: Path, title: Optional[str] = None) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Update the record after successful sync.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
file_path: Path to the file
|
|
93
|
+
title: Updated title (if changed)
|
|
94
|
+
"""
|
|
95
|
+
self.status = "synced"
|
|
96
|
+
self.last_synced = datetime.now().isoformat()
|
|
97
|
+
self.last_modified = file_path.stat().st_mtime
|
|
98
|
+
|
|
99
|
+
if title:
|
|
100
|
+
self.title = title
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class SyncStatusTracker:
|
|
105
|
+
"""
|
|
106
|
+
Tracks sync status for all files in a project.
|
|
107
|
+
|
|
108
|
+
Attributes:
|
|
109
|
+
status_file: Path to the status file
|
|
110
|
+
files: Dictionary mapping page IDs to sync records
|
|
111
|
+
by_path: Dictionary mapping relative paths to sync records
|
|
112
|
+
root_dir: Root directory for calculating relative paths
|
|
113
|
+
"""
|
|
114
|
+
status_file: Path
|
|
115
|
+
root_dir: Path
|
|
116
|
+
files: Dict[str, FileSyncRecord] = field(default_factory=dict)
|
|
117
|
+
by_path: Dict[str, FileSyncRecord] = field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def load_or_create(cls, status_file_path: Path, root_dir: Path) -> "SyncStatusTracker":
|
|
121
|
+
"""
|
|
122
|
+
Load the status tracker from a file or create a new one.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
status_file_path: Path to the status file
|
|
126
|
+
root_dir: Root directory for calculating relative paths
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
SyncStatusTracker instance
|
|
130
|
+
"""
|
|
131
|
+
tracker = cls(status_file=status_file_path, root_dir=root_dir)
|
|
132
|
+
|
|
133
|
+
if status_file_path.exists():
|
|
134
|
+
tracker.load()
|
|
135
|
+
|
|
136
|
+
return tracker
|
|
137
|
+
|
|
138
|
+
def load(self) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Load sync status from the status file.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
with open(self.status_file, "r", encoding="utf-8") as f:
|
|
144
|
+
data = json.load(f)
|
|
145
|
+
|
|
146
|
+
# Reset existing data
|
|
147
|
+
self.files = {}
|
|
148
|
+
self.by_path = {}
|
|
149
|
+
|
|
150
|
+
# Load records
|
|
151
|
+
for page_id, record_data in data.get("files", {}).items():
|
|
152
|
+
record = FileSyncRecord(
|
|
153
|
+
relative_path=record_data["relative_path"],
|
|
154
|
+
page_id=page_id,
|
|
155
|
+
title=record_data["title"],
|
|
156
|
+
last_synced=record_data["last_synced"],
|
|
157
|
+
last_modified=record_data["last_modified"],
|
|
158
|
+
status=record_data.get("status", "synced"),
|
|
159
|
+
history=record_data.get("history", []),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
self.files[page_id] = record
|
|
163
|
+
self.by_path[record.relative_path] = record
|
|
164
|
+
|
|
165
|
+
logger.debug(f"Loaded sync status for {len(self.files)} files")
|
|
166
|
+
|
|
167
|
+
except (json.JSONDecodeError, FileNotFoundError) as e:
|
|
168
|
+
logger.warning(f"Failed to load sync status file: {e}")
|
|
169
|
+
|
|
170
|
+
def save(self) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Save sync status to the status file.
|
|
173
|
+
"""
|
|
174
|
+
# Create data structure for serialization
|
|
175
|
+
data = {
|
|
176
|
+
"files": {record.page_id: asdict(record) for record in self.files.values()},
|
|
177
|
+
"last_updated": datetime.now().isoformat(),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
# Ensure directory exists
|
|
182
|
+
self.status_file.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
|
|
184
|
+
# Write to file
|
|
185
|
+
with open(self.status_file, "w", encoding="utf-8") as f:
|
|
186
|
+
json.dump(data, f, indent=2)
|
|
187
|
+
|
|
188
|
+
logger.debug(f"Saved sync status for {len(self.files)} files")
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error(f"Failed to save sync status file: {e}")
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
def add_or_update_file(self, file_path: Path, page_id: str, title: str) -> FileSyncRecord:
|
|
195
|
+
"""
|
|
196
|
+
Add or update a file in the tracker.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
file_path: Path to the file
|
|
200
|
+
page_id: Confluence page ID
|
|
201
|
+
title: Page title
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The sync record
|
|
205
|
+
"""
|
|
206
|
+
relative_path = safe_relative_path(file_path, self.root_dir)
|
|
207
|
+
|
|
208
|
+
# Check if this path already exists with a different page ID
|
|
209
|
+
if relative_path in self.by_path and self.by_path[relative_path].page_id != page_id:
|
|
210
|
+
# This is a new file at a path that was previously used by a different file
|
|
211
|
+
# Remove the old path mapping
|
|
212
|
+
old_record = self.by_path[relative_path]
|
|
213
|
+
del self.by_path[relative_path]
|
|
214
|
+
|
|
215
|
+
if old_record.page_id in self.files:
|
|
216
|
+
logger.warning(f"Path {relative_path} was previously used by page {old_record.page_id}, "
|
|
217
|
+
f"now used by {page_id}")
|
|
218
|
+
|
|
219
|
+
# Mark the old record as deleted if it's still in files
|
|
220
|
+
old_record.mark_as_deleted()
|
|
221
|
+
|
|
222
|
+
# Check if the page ID already exists but with a different path
|
|
223
|
+
if page_id in self.files:
|
|
224
|
+
record = self.files[page_id]
|
|
225
|
+
old_path = record.relative_path
|
|
226
|
+
|
|
227
|
+
# If path changed, this is a rename
|
|
228
|
+
if old_path != relative_path:
|
|
229
|
+
# Update path mappings
|
|
230
|
+
if old_path in self.by_path:
|
|
231
|
+
del self.by_path[old_path]
|
|
232
|
+
|
|
233
|
+
# Mark as renamed
|
|
234
|
+
record.mark_as_renamed(relative_path)
|
|
235
|
+
logger.info(f"Detected rename: {old_path} -> {relative_path}")
|
|
236
|
+
|
|
237
|
+
# Update other fields
|
|
238
|
+
record.title = title
|
|
239
|
+
record.last_synced = datetime.now().isoformat()
|
|
240
|
+
record.last_modified = file_path.stat().st_mtime
|
|
241
|
+
|
|
242
|
+
# Update the path mapping
|
|
243
|
+
self.by_path[relative_path] = record
|
|
244
|
+
|
|
245
|
+
return record
|
|
246
|
+
else:
|
|
247
|
+
# New file
|
|
248
|
+
record = FileSyncRecord.from_file(file_path, self.root_dir, page_id, title)
|
|
249
|
+
|
|
250
|
+
# Add to mappings
|
|
251
|
+
self.files[page_id] = record
|
|
252
|
+
self.by_path[relative_path] = record
|
|
253
|
+
|
|
254
|
+
return record
|
|
255
|
+
|
|
256
|
+
def detect_changes(self, current_files: Set[Path]) -> Dict[str, List[FileSyncRecord]]:
|
|
257
|
+
"""
|
|
258
|
+
Detect renamed and deleted files.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
current_files: Set of current file paths
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Dictionary with "renamed" and "deleted" lists of records
|
|
265
|
+
"""
|
|
266
|
+
changes = {
|
|
267
|
+
"renamed": [],
|
|
268
|
+
"deleted": [],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
current_relative_paths = {safe_relative_path(f, self.root_dir) for f in current_files}
|
|
272
|
+
|
|
273
|
+
# Check for missing files
|
|
274
|
+
for path, record in list(self.by_path.items()):
|
|
275
|
+
if path not in current_relative_paths:
|
|
276
|
+
if record.status != "deleted":
|
|
277
|
+
logger.info(f"File missing: {path} (page ID: {record.page_id})")
|
|
278
|
+
|
|
279
|
+
# Check if the file might have been renamed
|
|
280
|
+
renamed = False
|
|
281
|
+
for current_path in current_relative_paths:
|
|
282
|
+
if (current_path not in self.by_path and
|
|
283
|
+
Path(current_path).name == Path(path).name):
|
|
284
|
+
# Potential rename - same filename but different directory
|
|
285
|
+
record.mark_as_renamed(current_path)
|
|
286
|
+
self.by_path[current_path] = record
|
|
287
|
+
del self.by_path[path]
|
|
288
|
+
changes["renamed"].append(record)
|
|
289
|
+
renamed = True
|
|
290
|
+
logger.info(f"Detected potential rename: {path} -> {current_path}")
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
if not renamed:
|
|
294
|
+
# Mark as deleted
|
|
295
|
+
record.mark_as_deleted()
|
|
296
|
+
changes["deleted"].append(record)
|
|
297
|
+
|
|
298
|
+
return changes
|
|
299
|
+
|
|
300
|
+
def get_record_by_path(self, file_path: Union[str, Path]) -> Optional[FileSyncRecord]:
|
|
301
|
+
"""
|
|
302
|
+
Get sync record by path.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
file_path: File path (absolute or relative)
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
FileSyncRecord or None if not found
|
|
309
|
+
"""
|
|
310
|
+
# Convert to relative path if needed
|
|
311
|
+
if isinstance(file_path, Path):
|
|
312
|
+
if file_path.is_absolute():
|
|
313
|
+
rel_path = safe_relative_path(file_path, self.root_dir)
|
|
314
|
+
else:
|
|
315
|
+
rel_path = str(file_path)
|
|
316
|
+
else:
|
|
317
|
+
rel_path = file_path
|
|
318
|
+
|
|
319
|
+
return self.by_path.get(rel_path)
|
|
320
|
+
|
|
321
|
+
def get_record_by_id(self, page_id: str) -> Optional[FileSyncRecord]:
|
|
322
|
+
"""
|
|
323
|
+
Get sync record by Confluence page ID.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
page_id: Confluence page ID
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
FileSyncRecord or None if not found
|
|
330
|
+
"""
|
|
331
|
+
return self.files.get(page_id)
|
|
332
|
+
|
|
333
|
+
def remove_file(self, file_path: Union[str, Path]) -> Optional[FileSyncRecord]:
|
|
334
|
+
"""
|
|
335
|
+
Remove a file from tracking.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
file_path: File path
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Removed record or None if not found
|
|
342
|
+
"""
|
|
343
|
+
record = self.get_record_by_path(file_path)
|
|
344
|
+
|
|
345
|
+
if record:
|
|
346
|
+
# Remove from mappings
|
|
347
|
+
if record.relative_path in self.by_path:
|
|
348
|
+
del self.by_path[record.relative_path]
|
|
349
|
+
|
|
350
|
+
if record.page_id in self.files:
|
|
351
|
+
del self.files[record.page_id]
|
|
352
|
+
|
|
353
|
+
return record
|
|
354
|
+
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
def get_all_synced_files(self) -> List[FileSyncRecord]:
|
|
358
|
+
"""
|
|
359
|
+
Get all successfully synced files.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
List of sync records for synced files
|
|
363
|
+
"""
|
|
364
|
+
return [r for r in self.files.values() if r.status == "synced"]
|
|
365
|
+
|
|
366
|
+
def get_all_deleted_files(self) -> List[FileSyncRecord]:
|
|
367
|
+
"""
|
|
368
|
+
Get all deleted files.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
List of sync records for deleted files
|
|
372
|
+
"""
|
|
373
|
+
return [r for r in self.files.values() if r.status == "deleted"]
|
|
374
|
+
|
|
375
|
+
def get_all_tracked_files(self) -> Dict[str, FileSyncRecord]:
|
|
376
|
+
"""
|
|
377
|
+
Get a dictionary of all tracked files by relative path.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Dictionary mapping relative paths to sync records
|
|
381
|
+
"""
|
|
382
|
+
return self.by_path
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Confluence API integration module.
|
|
3
|
+
|
|
4
|
+
This module provides functionality for interacting with the Confluence API,
|
|
5
|
+
including specialized clients for different Confluence resources.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from docspan.backends.confluence.services.confluence.attachment_client import AttachmentClient
|
|
9
|
+
from docspan.backends.confluence.services.confluence.base_client import (
|
|
10
|
+
ArchivedPageError,
|
|
11
|
+
BaseConfluenceClient,
|
|
12
|
+
ConfluenceApiError,
|
|
13
|
+
PageNotFoundError,
|
|
14
|
+
RestrictedPageError,
|
|
15
|
+
)
|
|
16
|
+
from docspan.backends.confluence.services.confluence.client import ConfluenceClient
|
|
17
|
+
from docspan.backends.confluence.services.confluence.comment_client import (
|
|
18
|
+
CommentNotFoundError,
|
|
19
|
+
ConfluenceCommentClient,
|
|
20
|
+
InlineCommentNotSupportedError,
|
|
21
|
+
)
|
|
22
|
+
from docspan.backends.confluence.services.confluence.label_client import LabelClient
|
|
23
|
+
from docspan.backends.confluence.services.confluence.page_client import PageClient
|
|
24
|
+
from docspan.backends.confluence.services.confluence.space_client import SpaceClient
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ConfluenceClient",
|
|
28
|
+
"BaseConfluenceClient",
|
|
29
|
+
"PageClient",
|
|
30
|
+
"AttachmentClient",
|
|
31
|
+
"LabelClient",
|
|
32
|
+
"SpaceClient",
|
|
33
|
+
"ConfluenceCommentClient",
|
|
34
|
+
"ConfluenceApiError",
|
|
35
|
+
"ArchivedPageError",
|
|
36
|
+
"PageNotFoundError",
|
|
37
|
+
"RestrictedPageError",
|
|
38
|
+
"CommentNotFoundError",
|
|
39
|
+
"InlineCommentNotSupportedError",
|
|
40
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client for managing Confluence attachments.
|
|
3
|
+
|
|
4
|
+
This module provides specialized functionality for working with Confluence attachments,
|
|
5
|
+
including uploading and retrieving attachments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from docspan.backends.confluence.config.models import ConfluenceConfig
|
|
12
|
+
from docspan.backends.confluence.services.confluence.base_client import BaseConfluenceClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AttachmentClient(BaseConfluenceClient):
|
|
16
|
+
"""
|
|
17
|
+
Client for managing Confluence attachments.
|
|
18
|
+
|
|
19
|
+
Provides methods for uploading and retrieving attachments.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: ConfluenceConfig):
|
|
23
|
+
"""
|
|
24
|
+
Initialize the attachment client.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config: Confluence configuration
|
|
28
|
+
"""
|
|
29
|
+
super().__init__(config)
|
|
30
|
+
|
|
31
|
+
def upload_attachment(
|
|
32
|
+
self,
|
|
33
|
+
page_id: str,
|
|
34
|
+
file_path: Path,
|
|
35
|
+
comment: Optional[str] = None
|
|
36
|
+
) -> Dict[str, Any]:
|
|
37
|
+
"""
|
|
38
|
+
Upload an attachment to a Confluence page.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
page_id: Confluence page ID
|
|
42
|
+
file_path: Path to the file to upload
|
|
43
|
+
comment: Optional comment for the attachment
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Attachment data
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
FileNotFoundError: If the file doesn't exist
|
|
50
|
+
ConfluenceApiError: For API errors
|
|
51
|
+
"""
|
|
52
|
+
if not file_path.exists():
|
|
53
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
54
|
+
|
|
55
|
+
# Set up request details
|
|
56
|
+
endpoint = f"content/{page_id}/child/attachment"
|
|
57
|
+
headers = {"X-Atlassian-Token": "no-check"}
|
|
58
|
+
data = {}
|
|
59
|
+
|
|
60
|
+
if comment:
|
|
61
|
+
data["comment"] = comment
|
|
62
|
+
|
|
63
|
+
# Open file for upload
|
|
64
|
+
with file_path.open("rb") as file_obj:
|
|
65
|
+
files = {"file": (file_path.name, file_obj)}
|
|
66
|
+
|
|
67
|
+
# Make the request
|
|
68
|
+
response = self._make_request(
|
|
69
|
+
method="POST",
|
|
70
|
+
endpoint=endpoint,
|
|
71
|
+
headers=headers,
|
|
72
|
+
data=data,
|
|
73
|
+
files=files
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Return the first result (there should only be one)
|
|
77
|
+
if isinstance(response, dict) and "results" in response:
|
|
78
|
+
return response["results"][0]
|
|
79
|
+
|
|
80
|
+
return response
|
|
81
|
+
|
|
82
|
+
def get_attachments(self, page_id: str) -> List[Dict[str, Any]]:
|
|
83
|
+
"""
|
|
84
|
+
Get all attachments for a page.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
page_id: Confluence page ID
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of attachment data
|
|
91
|
+
"""
|
|
92
|
+
endpoint = f"content/{page_id}/child/attachment"
|
|
93
|
+
params = {"limit": 100} # Set a reasonable limit
|
|
94
|
+
|
|
95
|
+
response = self._make_request(
|
|
96
|
+
method="GET",
|
|
97
|
+
endpoint=endpoint,
|
|
98
|
+
params=params
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Return the results
|
|
102
|
+
if isinstance(response, dict):
|
|
103
|
+
return response.get("results", [])
|
|
104
|
+
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
def get_attachment_content(self, attachment_id: str) -> bytes:
|
|
108
|
+
"""
|
|
109
|
+
Get the content of an attachment.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
attachment_id: Attachment ID
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Binary content of the attachment
|
|
116
|
+
"""
|
|
117
|
+
endpoint = f"content/{attachment_id}/download"
|
|
118
|
+
|
|
119
|
+
# Use session directly to get binary content
|
|
120
|
+
url = f"{self.rest_api_url}/{endpoint.lstrip('/')}"
|
|
121
|
+
response = self.session.get(url)
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
|
|
124
|
+
return response.content
|
|
125
|
+
|
|
126
|
+
def delete_attachment(self, attachment_id: str) -> Dict[str, Any]:
|
|
127
|
+
"""
|
|
128
|
+
Delete an attachment from Confluence.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
attachment_id: Attachment ID
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Deletion result
|
|
135
|
+
"""
|
|
136
|
+
endpoint = f"content/{attachment_id}"
|
|
137
|
+
|
|
138
|
+
response = self._make_request(
|
|
139
|
+
method="DELETE",
|
|
140
|
+
endpoint=endpoint
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# For DELETE operations with no response body
|
|
144
|
+
if not response or (isinstance(response, dict) and not response):
|
|
145
|
+
return {"status": "success", "id": attachment_id}
|
|
146
|
+
|
|
147
|
+
return response
|