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,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
|
+
|