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,471 @@
1
+ """
2
+ Document Converter Module
3
+
4
+ Handles conversion between Google Docs and Markdown formats
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ import sys
10
+
11
+ from markdownify import markdownify as md
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Increase recursion limit for deeply nested lists
16
+ sys.setrecursionlimit(10000)
17
+
18
+ # Configuration for large documents
19
+ MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB
20
+
21
+
22
+ class DocumentConverter:
23
+ """Converter for Google Docs <-> Markdown"""
24
+
25
+ @staticmethod
26
+ def html_to_markdown(html_content: str) -> str:
27
+ """
28
+ Convert Google Docs HTML to Markdown
29
+
30
+ Args:
31
+ html_content: HTML content from Google Docs
32
+
33
+ Returns:
34
+ str: Markdown formatted content
35
+ """
36
+ try:
37
+ content_size = len(html_content)
38
+ logger.info(f"Converting HTML to Markdown (size: {content_size:,} bytes)")
39
+
40
+ # Check if content is too large
41
+ if content_size > MAX_CONTENT_LENGTH:
42
+ logger.warning(f"Large document detected: {content_size:,} bytes")
43
+
44
+ # Pre-process: Remove style and script tags with content
45
+ # Use more efficient non-greedy matching for large documents
46
+ html_content = re.sub(r'<style[^>]*>.*?</style>', '', html_content, flags=re.DOTALL | re.IGNORECASE)
47
+ html_content = re.sub(r'<script[^>]*>.*?</script>', '', html_content, flags=re.DOTALL | re.IGNORECASE)
48
+
49
+ # Remove head section entirely
50
+ html_content = re.sub(r'<head[^>]*>.*?</head>', '', html_content, flags=re.DOTALL | re.IGNORECASE)
51
+
52
+ logger.info("Reconstructing nested lists...")
53
+ # Reconstruct nested lists based on margin-left
54
+ html_content = DocumentConverter._reconstruct_nested_lists(html_content)
55
+
56
+ # Convert HTML to Markdown
57
+ markdown = md(
58
+ html_content,
59
+ heading_style="ATX", # Use # for headings
60
+ bullets="-", # Use - for unordered lists
61
+ strong_em_symbol="**", # Use ** for bold
62
+ strip=['style', 'script'] # Remove style and script tags
63
+ )
64
+
65
+ # Keep tabs for nested lists (Obsidian uses tabs for list indentation)
66
+ # Do NOT convert tabs to spaces
67
+
68
+ # Clean up extra whitespace
69
+ markdown = re.sub(r'\n{3,}', '\n\n', markdown) # Max 2 consecutive newlines
70
+ markdown = markdown.strip()
71
+
72
+ # Remove trailing spaces from each line (important for proper list rendering)
73
+ markdown = re.sub(r' +$', '', markdown, flags=re.MULTILINE)
74
+
75
+ # Remove Google Docs specific artifacts
76
+ markdown = DocumentConverter._clean_google_docs_artifacts(markdown)
77
+
78
+ logger.info("Converted HTML to Markdown")
79
+ return markdown
80
+
81
+ except Exception as e:
82
+ logger.error(f"Error converting HTML to Markdown: {e}")
83
+ raise
84
+
85
+ @staticmethod
86
+ def _reconstruct_nested_lists(html: str) -> str:
87
+ """
88
+ Reconstruct nested list structure based on class names and margin-left
89
+
90
+ Google Docs exports lists as separate <ul> blocks with class names
91
+ indicating list groups and nesting levels. This function groups
92
+ consecutive related <ul> blocks and rebuilds proper nested structure.
93
+
94
+ Args:
95
+ html: HTML content with Google Docs list structure
96
+
97
+ Returns:
98
+ str: HTML with reconstructed nested lists
99
+ """
100
+ try:
101
+ from html.parser import HTMLParser
102
+ except Exception as e:
103
+ logger.warning(f"Error importing HTMLParser, skipping list reconstruction: {e}")
104
+ return html
105
+
106
+ class ListItem:
107
+ def __init__(self, level, content, class_level=None):
108
+ self.level = level
109
+ self.content = content
110
+ self.class_level = class_level # Level from class name (-0, -1, -2)
111
+ self.children = []
112
+
113
+ class GoogleListParser(HTMLParser):
114
+ def __init__(self):
115
+ super().__init__()
116
+ self.items = []
117
+ self.current_item = None
118
+ self.in_li = False
119
+ self.li_content = []
120
+
121
+ def handle_starttag(self, tag, attrs):
122
+ if tag == 'li':
123
+ self.in_li = True
124
+ self.li_content = []
125
+ # Extract margin-left from style attribute
126
+ style = dict(attrs).get('style', '')
127
+ margin_match = re.search(r'margin-left:\s*(\d+)pt', style)
128
+ if margin_match:
129
+ margin = int(margin_match.group(1))
130
+ # Calculate level: each 36pt is one level
131
+ level = max(0, (margin // 36) - 1)
132
+ else:
133
+ level = 0
134
+ self.current_item = ListItem(level, '')
135
+ elif self.in_li:
136
+ # Reconstruct tag for content
137
+ attrs_str = ' '.join([f'{k}="{v}"' for k, v in attrs])
138
+ self.li_content.append(f'<{tag} {attrs_str}>' if attrs_str else f'<{tag}>')
139
+
140
+ def handle_endtag(self, tag):
141
+ if tag == 'li' and self.in_li:
142
+ self.current_item.content = ''.join(self.li_content)
143
+ self.items.append(self.current_item)
144
+ self.in_li = False
145
+ self.current_item = None
146
+ elif self.in_li:
147
+ self.li_content.append(f'</{tag}>')
148
+
149
+ def handle_data(self, data):
150
+ if self.in_li:
151
+ self.li_content.append(data)
152
+
153
+ # Extract class name and level from <ul> tag
154
+ def extract_list_info(ul_tag):
155
+ """Extract list prefix and level from <ul> class attribute"""
156
+ class_match = re.search(r'class="([^"]*)"', ul_tag)
157
+ if not class_match:
158
+ return None, None
159
+
160
+ class_name = class_match.group(1)
161
+ # Pattern: lst-kix_XXXXX-N where N is the level
162
+ level_match = re.search(r'(lst-kix_[a-z0-9]+)-(\d+)', class_name)
163
+ if level_match:
164
+ prefix = level_match.group(1) # e.g., "lst-kix_dnvzcw1w4rp0"
165
+ level = int(level_match.group(2)) # e.g., 0, 1, 2
166
+ return prefix, level
167
+ return None, None
168
+
169
+ # Group consecutive <ul> blocks that belong together
170
+ def group_consecutive_lists(html_content):
171
+ """Find groups of consecutive <ul> blocks with same prefix"""
172
+ groups = []
173
+ current_group = []
174
+ current_prefix = None
175
+ last_end = 0
176
+
177
+ # Find all <ul>...</ul> blocks
178
+ for match in re.finditer(r'<ul[^>]*>.*?</ul>', html_content, re.DOTALL):
179
+ ul_block = match.group(0)
180
+ prefix, level = extract_list_info(ul_block)
181
+
182
+ # Get content before this <ul> (non-list content)
183
+ before_content = html_content[last_end:match.start()]
184
+
185
+ # Check if this continues the current group
186
+ if prefix and prefix == current_prefix:
187
+ # Same group, add to current
188
+ current_group.append((ul_block, level, match.start(), match.end()))
189
+ else:
190
+ # Different group or first group
191
+ if current_group:
192
+ # Save previous group
193
+ groups.append({
194
+ 'prefix': current_prefix,
195
+ 'blocks': current_group,
196
+ 'before': groups[-1]['after'] if groups else html_content[:current_group[0][2]],
197
+ 'start': current_group[0][2],
198
+ 'end': current_group[-1][3]
199
+ })
200
+
201
+ # Start new group
202
+ if prefix:
203
+ current_prefix = prefix
204
+ current_group = [(ul_block, level, match.start(), match.end())]
205
+ else:
206
+ # No prefix, treat as standalone
207
+ if before_content.strip():
208
+ groups.append({
209
+ 'prefix': None,
210
+ 'blocks': [(ul_block, None, match.start(), match.end())],
211
+ 'before': before_content,
212
+ 'start': match.start(),
213
+ 'end': match.end()
214
+ })
215
+ else:
216
+ current_group = [(ul_block, None, match.start(), match.end())]
217
+ current_prefix = None
218
+
219
+ last_end = match.end()
220
+
221
+ # Don't forget the last group
222
+ if current_group:
223
+ groups.append({
224
+ 'prefix': current_prefix,
225
+ 'blocks': current_group,
226
+ 'before': groups[-1]['after'] if groups else html_content[:current_group[0][2]],
227
+ 'start': current_group[0][2],
228
+ 'end': current_group[-1][3]
229
+ })
230
+ groups[-1]['after'] = html_content[last_end:]
231
+
232
+ return groups
233
+
234
+ # Build nested HTML from grouped items
235
+ def build_nested_html(items):
236
+ if not items:
237
+ return ''
238
+
239
+ html_parts = []
240
+ i = 0
241
+ while i < len(items):
242
+ item = items[i]
243
+ html_parts.append(f'<li>{item.content}')
244
+
245
+ # Check if next items are children (higher level)
246
+ j = i + 1
247
+ while j < len(items) and items[j].level > item.level:
248
+ j += 1
249
+
250
+ if j > i + 1:
251
+ # Has children
252
+ children = items[i+1:j]
253
+ html_parts.append('<ul>')
254
+ html_parts.append(build_nested_html(children))
255
+ html_parts.append('</ul>')
256
+ i = j
257
+ else:
258
+ i += 1
259
+
260
+ html_parts.append('</li>')
261
+
262
+ return ''.join(html_parts)
263
+
264
+ # Process groups and merge consecutive blocks
265
+ def process_list_group(group):
266
+ """Process a group of related <ul> blocks"""
267
+ if not group or not group['blocks']:
268
+ return group['blocks'][0][0] if group['blocks'] else ''
269
+
270
+ # Parse all blocks in this group
271
+ all_items = []
272
+ for ul_block, class_level, _, _ in group['blocks']:
273
+ parser = GoogleListParser()
274
+ try:
275
+ parser.feed(ul_block)
276
+ # Update class_level if available
277
+ if class_level is not None:
278
+ for item in parser.items:
279
+ item.class_level = class_level
280
+ # Use class level if it's more reliable
281
+ if class_level > 0:
282
+ item.level = class_level
283
+ all_items.extend(parser.items)
284
+ except Exception:
285
+ pass
286
+
287
+ if not all_items:
288
+ return ''.join([block[0] for block in group['blocks']])
289
+
290
+ # Rebuild with proper nesting
291
+ return f'<ul>{build_nested_html(all_items)}</ul>'
292
+
293
+ # Main processing: group consecutive lists and rebuild with proper nesting
294
+ # This approach merges <ul> blocks that belong together while preserving non-list content
295
+
296
+ try:
297
+ # Find all <ul> positions and group them
298
+ ul_positions = []
299
+ for match in re.finditer(r'<ul[^>]*>.*?</ul>', html, re.DOTALL):
300
+ ul_block = match.group(0)
301
+ prefix, level = extract_list_info(ul_block)
302
+ ul_positions.append({
303
+ 'start': match.start(),
304
+ 'end': match.end(),
305
+ 'block': ul_block,
306
+ 'prefix': prefix,
307
+ 'level': level
308
+ })
309
+
310
+ if not ul_positions:
311
+ return html
312
+ except Exception as e:
313
+ logger.warning(f"Error finding list positions, returning original HTML: {e}")
314
+ return html
315
+
316
+ try:
317
+ # Group consecutive <ul> blocks with same prefix
318
+ result_parts = []
319
+ last_pos = 0
320
+ i = 0
321
+
322
+ while i < len(ul_positions):
323
+ # Add non-list content before this block
324
+ result_parts.append(html[last_pos:ul_positions[i]['start']])
325
+
326
+ # Check if next blocks belong to the same group (same prefix)
327
+ current_prefix = ul_positions[i]['prefix']
328
+ group_blocks = [ul_positions[i]]
329
+ j = i + 1
330
+
331
+ # Look ahead to find consecutive blocks with same prefix
332
+ while j < len(ul_positions):
333
+ # Check if there's only whitespace between blocks
334
+ between_content = html[ul_positions[j-1]['end']:ul_positions[j]['start']]
335
+ is_consecutive = between_content.strip() == ''
336
+
337
+ if is_consecutive and ul_positions[j]['prefix'] == current_prefix:
338
+ group_blocks.append(ul_positions[j])
339
+ j += 1
340
+ else:
341
+ break
342
+
343
+ # Process this group
344
+ if len(group_blocks) > 1 and current_prefix:
345
+ # Multiple blocks with same prefix - merge them
346
+ all_items = []
347
+ for block_info in group_blocks:
348
+ parser = GoogleListParser()
349
+ try:
350
+ parser.feed(block_info['block'])
351
+ # Set level from class if available
352
+ if block_info['level'] is not None:
353
+ for item in parser.items:
354
+ item.class_level = block_info['level']
355
+ # Use class level as the authoritative level
356
+ item.level = block_info['level']
357
+ all_items.extend(parser.items)
358
+ except Exception:
359
+ pass
360
+
361
+ if all_items:
362
+ result_parts.append(f'<ul>{build_nested_html(all_items)}</ul>')
363
+ else:
364
+ # Fallback: keep original blocks
365
+ for block_info in group_blocks:
366
+ result_parts.append(block_info['block'])
367
+
368
+ last_pos = group_blocks[-1]['end']
369
+ i = j
370
+ else:
371
+ # Single block or no prefix - process normally
372
+ parser = GoogleListParser()
373
+ try:
374
+ parser.feed(group_blocks[0]['block'])
375
+ if parser.items:
376
+ result_parts.append(f'<ul>{build_nested_html(parser.items)}</ul>')
377
+ else:
378
+ result_parts.append(group_blocks[0]['block'])
379
+ except Exception:
380
+ result_parts.append(group_blocks[0]['block'])
381
+
382
+ last_pos = group_blocks[0]['end']
383
+ i += 1
384
+
385
+ # Add remaining content
386
+ result_parts.append(html[last_pos:])
387
+
388
+ reconstructed_html = ''.join(result_parts)
389
+ logger.info(f"List reconstruction completed (original: {len(html):,} -> result: {len(reconstructed_html):,} bytes)")
390
+ return reconstructed_html
391
+
392
+ except Exception as e:
393
+ logger.error(f"Error during list reconstruction: {e}", exc_info=True)
394
+ logger.warning("Returning original HTML without list reconstruction")
395
+ return html
396
+
397
+ @staticmethod
398
+ def _clean_google_docs_artifacts(markdown: str) -> str:
399
+ """
400
+ Clean Google Docs specific artifacts from markdown
401
+
402
+ Args:
403
+ markdown: Markdown content
404
+
405
+ Returns:
406
+ str: Cleaned markdown
407
+ """
408
+ # Remove empty links
409
+ markdown = re.sub(r'\[\]\(.*?\)', '', markdown)
410
+
411
+ # Clean up excessive spacing in lists
412
+ markdown = re.sub(r'(\n-\s+)\n+', r'\1', markdown)
413
+
414
+ # Remove zero-width spaces and other invisible characters
415
+ markdown = markdown.replace('\u200b', '') # Zero-width space
416
+ markdown = markdown.replace('\ufeff', '') # Zero-width no-break space
417
+
418
+ return markdown
419
+
420
+ @staticmethod
421
+ def markdown_to_plain_text(markdown_content: str) -> str:
422
+ """
423
+ Convert Markdown to plain text (for updating Google Docs)
424
+
425
+ This is a simple conversion that removes markdown formatting
426
+ but preserves the text structure.
427
+
428
+ Args:
429
+ markdown_content: Markdown formatted content
430
+
431
+ Returns:
432
+ str: Plain text content
433
+ """
434
+ try:
435
+ text = markdown_content
436
+
437
+ # Remove code blocks
438
+ text = re.sub(r'```[\s\S]*?```', '', text)
439
+ text = re.sub(r'`([^`]+)`', r'\1', text)
440
+
441
+ # Convert headers to plain text (keep the text, add newlines)
442
+ text = re.sub(r'^#{1,6}\s+(.+)$', r'\1\n', text, flags=re.MULTILINE)
443
+
444
+ # Convert bold and italic
445
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
446
+ text = re.sub(r'\*(.+?)\*', r'\1', text)
447
+ text = re.sub(r'__(.+?)__', r'\1', text)
448
+ text = re.sub(r'_(.+?)_', r'\1', text)
449
+
450
+ # Convert links [text](url) to just text
451
+ text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
452
+
453
+ # Convert lists while preserving indentation
454
+ # Convert tabs to 4 spaces for Google Docs compatibility
455
+ text = text.replace('\t', ' ')
456
+ # Remove list markers but keep indentation
457
+ text = re.sub(r'^(\s*)[-*+]\s+', r'\1', text, flags=re.MULTILINE)
458
+ text = re.sub(r'^(\s*)\d+\.\s+', r'\1', text, flags=re.MULTILINE)
459
+
460
+ # Clean up extra whitespace
461
+ text = re.sub(r'\n{3,}', '\n\n', text)
462
+ text = text.strip()
463
+
464
+ logger.info("Converted Markdown to plain text")
465
+ return text
466
+
467
+ except Exception as e:
468
+ logger.error(f"Error converting Markdown to plain text: {e}")
469
+ raise
470
+
471
+