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.
Files changed (65) hide show
  1. docspan/__init__.py +3 -0
  2. docspan/__main__.py +0 -0
  3. docspan/backends/__init__.py +19 -0
  4. docspan/backends/base.py +85 -0
  5. docspan/backends/confluence/__init__.py +0 -0
  6. docspan/backends/confluence/adf/__init__.py +14 -0
  7. docspan/backends/confluence/adf/comparator.py +427 -0
  8. docspan/backends/confluence/adf/converter.py +119 -0
  9. docspan/backends/confluence/adf/converters.py +1449 -0
  10. docspan/backends/confluence/adf/interfaces.py +191 -0
  11. docspan/backends/confluence/adf/nodes.py +2085 -0
  12. docspan/backends/confluence/adf/parser.py +400 -0
  13. docspan/backends/confluence/adf/validators.py +161 -0
  14. docspan/backends/confluence/adf/visitors.py +495 -0
  15. docspan/backends/confluence/backend.py +227 -0
  16. docspan/backends/confluence/client.py +44 -0
  17. docspan/backends/confluence/config/__init__.py +21 -0
  18. docspan/backends/confluence/config/loader.py +107 -0
  19. docspan/backends/confluence/config/models.py +167 -0
  20. docspan/backends/confluence/config/validation.py +297 -0
  21. docspan/backends/confluence/markdown/__init__.py +22 -0
  22. docspan/backends/confluence/markdown/ast.py +819 -0
  23. docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
  24. docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
  25. docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
  26. docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
  27. docspan/backends/confluence/markdown/inline_parser.py +495 -0
  28. docspan/backends/confluence/markdown/parser.py +1006 -0
  29. docspan/backends/confluence/models/__init__.py +18 -0
  30. docspan/backends/confluence/models/markdown_file.py +402 -0
  31. docspan/backends/confluence/models/page.py +212 -0
  32. docspan/backends/confluence/models/path_utils.py +34 -0
  33. docspan/backends/confluence/models/results.py +28 -0
  34. docspan/backends/confluence/models/sync_status.py +382 -0
  35. docspan/backends/confluence/services/__init__.py +0 -0
  36. docspan/backends/confluence/services/confluence/__init__.py +40 -0
  37. docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
  38. docspan/backends/confluence/services/confluence/base_client.py +420 -0
  39. docspan/backends/confluence/services/confluence/client.py +376 -0
  40. docspan/backends/confluence/services/confluence/comment_client.py +682 -0
  41. docspan/backends/confluence/services/confluence/crawler.py +587 -0
  42. docspan/backends/confluence/services/confluence/label_client.py +130 -0
  43. docspan/backends/confluence/services/confluence/page_client.py +1288 -0
  44. docspan/backends/confluence/services/confluence/space_client.py +179 -0
  45. docspan/backends/confluence/services/confluence/url_parser.py +106 -0
  46. docspan/backends/google_docs/__init__.py +0 -0
  47. docspan/backends/google_docs/auth.py +143 -0
  48. docspan/backends/google_docs/backend.py +140 -0
  49. docspan/backends/google_docs/client.py +665 -0
  50. docspan/backends/google_docs/converter.py +471 -0
  51. docspan/backends/google_docs/docs_request_builder.py +232 -0
  52. docspan/backends/google_docs/docs_structure_parser.py +120 -0
  53. docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
  54. docspan/cli/__init__.py +0 -0
  55. docspan/cli/main.py +408 -0
  56. docspan/config.py +62 -0
  57. docspan/core/__init__.py +49 -0
  58. docspan/core/merge.py +30 -0
  59. docspan/core/orchestrator.py +332 -0
  60. docspan/core/paths.py +8 -0
  61. docspan/core/state.py +53 -0
  62. docspan-0.1.0.dist-info/METADATA +273 -0
  63. docspan-0.1.0.dist-info/RECORD +65 -0
  64. docspan-0.1.0.dist-info/WHEEL +4 -0
  65. 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