mm-qa-mcp 0.2.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.
@@ -0,0 +1,976 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ XMind思维导图转换为Markdown文件工具
6
+ 增强版 - 支持更多XMind元素、批量处理和进度显示
7
+ 保留原始XMind文件,可选择创建备份
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import json
13
+ import zipfile
14
+ import shutil
15
+ import tempfile
16
+ import glob
17
+ import argparse
18
+ import time
19
+ import uuid
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from xml.etree import ElementTree as ET
22
+ import base64
23
+
24
+
25
+ class XMindToMarkdown:
26
+ def __init__(self, xmind_file, indent_style=" ", image_dir=None, backup_original=False):
27
+ """初始化转换器
28
+
29
+ Args:
30
+ xmind_file (file): XMind文件
31
+ indent_style (str, optional): Markdown列表缩进样式. 默认为两个空格.
32
+ image_dir (str, optional): 图片保存目录. 如果不指定,则使用Markdown文件相同目录下的images文件夹.
33
+ backup_original (bool, optional): 是否创建原始文件的备份. 默认为False.
34
+ """
35
+ self.xmind_file = xmind_file
36
+ self.temp_dir = tempfile.mkdtemp()
37
+ self.content_json = None
38
+ self.content_xml = None
39
+ self.markdown_content = []
40
+ self.indent_style = indent_style
41
+ self.image_dict = {} # 存储图片信息 {image_id: image_path}
42
+ self.topic_images = {} # 存储主题与图片的关联 {topic_id: [image_paths]}
43
+ self.notes_dict = {} # 存储备注信息
44
+ self.link_dict = {} # 存储链接信息
45
+ self.backup_original = backup_original
46
+ self.backup_file = None # 备份文件路径
47
+
48
+ # 设置图片目录
49
+ if image_dir:
50
+ self.image_dir = image_dir
51
+ else:
52
+ md_dir = os.path.dirname(os.path.abspath(
53
+ os.path.splitext(self.xmind_file)[0] + '.md'
54
+ ))
55
+ self.image_dir = os.path.join(md_dir, 'images')
56
+
57
+ # 确保图片目录存在
58
+ if not os.path.exists(self.image_dir):
59
+ os.makedirs(self.image_dir)
60
+
61
+ # 如果需要,创建原始文件的备份
62
+ if self.backup_original:
63
+ self._backup_original_file()
64
+
65
+ @staticmethod
66
+ def from_file_object(file_obj, output, indent_style=" ", image_dir=None, backup_original=False):
67
+ """从文件对象创建XMindToMarkdown实例并进行转换
68
+
69
+ Args:
70
+ file_obj: 输入的文件对象,支持多种类型:
71
+ - 类文件对象(有read方法)
72
+ - 包含content字段的字典
73
+ - 字节内容(bytes)
74
+ - 文件路径字符串(str)
75
+ output (str): 输出的Markdown文件名称(必传参数)
76
+ indent_style (str, optional): Markdown列表缩进样式,默认为两个空格
77
+ image_dir (str, optional): 图片保存目录,如不指定则使用Markdown文件相同目录下的images文件夹
78
+ backup_original (bool, optional): 是否创建原始文件的备份,默认为False
79
+
80
+ Returns:
81
+ dict: 包含生成的Markdown文件路径、内容和图片信息
82
+ """
83
+ try:
84
+ # 创建临时文件保存上传的文件内容
85
+ temp_dir = tempfile.gettempdir()
86
+ temp_file_path = os.path.join(temp_dir, f"{uuid.uuid4().hex}.xmind")
87
+
88
+ # 保存文件内容到临时文件
89
+ with open(temp_file_path, 'wb') as temp_file:
90
+ # 根据file_obj对象的类型进行不同处理
91
+ if hasattr(file_obj, 'read'):
92
+ # 如果是类文件对象,直接读取内容
93
+ temp_file.write(file_obj.read())
94
+ elif isinstance(file_obj, dict) and 'content' in file_obj:
95
+ # 如果是包含内容的字典
96
+ temp_file.write(file_obj['content'])
97
+ elif isinstance(file_obj, bytes):
98
+ # 如果是字节内容
99
+ temp_file.write(file_obj)
100
+ elif isinstance(file_obj, str):
101
+ # 如果是文件路径字符串
102
+ if os.path.exists(file_obj):
103
+ with open(file_obj, 'rb') as src_file:
104
+ temp_file.write(src_file.read())
105
+ else:
106
+ return {"error": f"文件 '{file_obj}' 不存在"}
107
+ else:
108
+ return {"error": "不支持的文件类型"}
109
+
110
+ # 执行转换
111
+ converter = XMindToMarkdown(
112
+ temp_file_path,
113
+ indent_style=indent_style,
114
+ image_dir=image_dir,
115
+ backup_original=backup_original
116
+ )
117
+
118
+ # 获取转换后的Markdown内容,但不保存到文件
119
+ markdown_content = converter.convert()
120
+
121
+ # 收集图片内容
122
+ image_contents = {}
123
+ # 遍历所有提取出的图片
124
+ for image_id, image_path in converter.image_dict.items():
125
+ if os.path.exists(image_path):
126
+ # 读取图片文件内容
127
+ with open(image_path, 'rb') as img_file:
128
+ # 使用相对路径作为键
129
+ rel_path = converter._get_relative_image_path(image_path)
130
+ # 将图片内容编码为base64字符串
131
+ image_contents[rel_path] = base64.b64encode(img_file.read()).decode('utf-8')
132
+
133
+ # 清理临时文件
134
+ try:
135
+ os.remove(temp_file_path)
136
+ except:
137
+ pass
138
+
139
+ return {
140
+ "markdown_file_path": output,
141
+ "markdown_content": markdown_content,
142
+ "image_file_path": converter.image_dir,
143
+ "image_content": image_contents
144
+ }
145
+ except Exception as e:
146
+ return {"error": f"转换失败: {str(e)}"}
147
+
148
+ def _backup_original_file(self):
149
+ """创建原始XMind文件的备份"""
150
+ base_name = os.path.basename(self.xmind_file)
151
+ backup_dir = os.path.join(os.path.dirname(self.xmind_file), 'xmind_backups')
152
+
153
+ # 确保备份目录存在
154
+ if not os.path.exists(backup_dir):
155
+ os.makedirs(backup_dir)
156
+
157
+ # 创建带有时间戳的备份文件名
158
+ file_name, file_ext = os.path.splitext(base_name)
159
+ timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
160
+ backup_name = f"{file_name}_{timestamp}{file_ext}"
161
+ self.backup_file = os.path.join(backup_dir, backup_name)
162
+
163
+ # 复制文件
164
+ shutil.copy2(self.xmind_file, self.backup_file)
165
+
166
+ def __del__(self):
167
+ """清理临时文件夹"""
168
+ if hasattr(self, 'temp_dir') and os.path.exists(self.temp_dir):
169
+ shutil.rmtree(self.temp_dir)
170
+
171
+ def extract_xmind(self):
172
+ """解压XMind文件到临时目录"""
173
+ try:
174
+ with zipfile.ZipFile(self.xmind_file, 'r') as zip_ref:
175
+ zip_ref.extractall(self.temp_dir)
176
+
177
+ # XMind Zen (新版XMind)使用content.json
178
+ content_json_path = os.path.join(self.temp_dir, 'content.json')
179
+
180
+ # XMind 8 (旧版XMind)使用content.xml
181
+ content_xml_path = os.path.join(self.temp_dir, 'content.xml')
182
+
183
+ if os.path.exists(content_json_path):
184
+ # 处理XMind Zen格式
185
+ with open(content_json_path, 'r', encoding='utf-8') as f:
186
+ self.content_json = json.load(f)
187
+ return 'json'
188
+ elif os.path.exists(content_xml_path):
189
+ # 处理XMind 8格式
190
+ self.content_xml = content_xml_path
191
+ return 'xml'
192
+ else:
193
+ raise Exception("无法识别的XMind文件格式")
194
+ except zipfile.BadZipFile:
195
+ raise Exception(f"文件 '{self.xmind_file}' 不是有效的XMind文件")
196
+ except Exception as e:
197
+ raise Exception(f"解压XMind文件失败: {str(e)}")
198
+
199
+ def extract_resources(self):
200
+ """提取XMind文件中的资源(如图片)"""
201
+ resources_dir = os.path.join(self.temp_dir, 'resources')
202
+ attachments_dir = os.path.join(self.temp_dir, 'attachments')
203
+
204
+ # 处理resources目录下的图片
205
+ if os.path.exists(resources_dir):
206
+ for root, _, files in os.walk(resources_dir):
207
+ for file in files:
208
+ if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.svg', '.bmp')):
209
+ # 获取图片ID(文件名)
210
+ image_id = file
211
+ src_path = os.path.join(root, file)
212
+ # 创建目标路径
213
+ unique_name = f"{str(uuid.uuid4())[:8]}_{file}"
214
+ dest_path = os.path.join(self.image_dir, unique_name)
215
+ # 复制文件
216
+ shutil.copy2(src_path, dest_path)
217
+ # 存储映射
218
+ self.image_dict[image_id] = dest_path
219
+
220
+ # 处理attachments目录下的图片
221
+ if os.path.exists(attachments_dir):
222
+ for root, _, files in os.walk(attachments_dir):
223
+ for file in files:
224
+ if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.svg', '.bmp')):
225
+ # 获取图片ID(文件名)
226
+ image_id = file
227
+ src_path = os.path.join(root, file)
228
+ # 创建目标路径
229
+ unique_name = f"{str(uuid.uuid4())[:8]}_{file}"
230
+ dest_path = os.path.join(self.image_dir, unique_name)
231
+ # 复制文件
232
+ shutil.copy2(src_path, dest_path)
233
+ # 存储映射
234
+ self.image_dict[image_id] = dest_path
235
+
236
+ def parse_json_content(self):
237
+ """解析XMind Zen格式的content.json文件"""
238
+ if not self.content_json:
239
+ return
240
+
241
+ # 尝试提取备注、链接和图片信息
242
+ self._extract_notes_json()
243
+ self._extract_images_json()
244
+
245
+ # 处理所有画布
246
+ for sheet_index, sheet in enumerate(self.content_json):
247
+ # 添加画布标题
248
+ sheet_title = sheet.get('title', f'画布 {sheet_index + 1}')
249
+ self.markdown_content.append(f"# {sheet_title}\n")
250
+
251
+ # 处理根主题(attached类型的主题)
252
+ root_topic = sheet.get('rootTopic', {})
253
+ if root_topic:
254
+ self._parse_topic_json(root_topic, 0)
255
+
256
+ # 处理同一画布内的其他主题(detached类型的主题)
257
+ detached_topics = []
258
+
259
+ # 检查是否有独立主题
260
+ if 'children' in root_topic and 'detached' in root_topic['children']:
261
+ detached_topics = root_topic['children']['detached']
262
+
263
+ # 如果找到了独立主题
264
+ if detached_topics:
265
+ self.markdown_content.append("\n## 其他主题\n")
266
+ for topic in detached_topics:
267
+ topic_title = topic.get('title', '未命名主题')
268
+ self.markdown_content.append(f"\n### {topic_title}\n")
269
+ self._parse_topic_json(topic, 0)
270
+
271
+ # 如果不是最后一个画布,添加分隔符
272
+ if sheet_index < len(self.content_json) - 1:
273
+ self.markdown_content.append("\n---\n")
274
+
275
+ def _extract_notes_json(self):
276
+ """从JSON中提取备注信息"""
277
+ for sheet in self.content_json:
278
+ self._extract_notes_from_topic_json(sheet.get('rootTopic', {}))
279
+
280
+ def _extract_images_json(self):
281
+ """从JSON中提取图片信息"""
282
+ for sheet in self.content_json:
283
+ self._extract_images_from_topic_json(sheet.get('rootTopic', {}))
284
+
285
+ def _extract_images_from_topic_json(self, topic):
286
+ """递归从主题中提取图片信息
287
+
288
+ Args:
289
+ topic (dict): 主题对象
290
+ """
291
+ if not topic:
292
+ return
293
+
294
+ # 提取主题ID
295
+ topic_id = topic.get('id', '')
296
+
297
+ # 检查是否有图片
298
+ if 'image' in topic:
299
+ image_data = topic['image']
300
+ # 检查是否有src属性(指向图片路径)
301
+ if 'src' in image_data:
302
+ image_src = image_data['src']
303
+ # 提取图片ID
304
+ image_id = os.path.basename(image_src)
305
+
306
+ # 关联主题与图片
307
+ if topic_id not in self.topic_images:
308
+ self.topic_images[topic_id] = []
309
+ self.topic_images[topic_id].append(image_id)
310
+
311
+ # 处理子主题
312
+ children = topic.get('children', {})
313
+ if 'attached' in children and isinstance(children['attached'], list):
314
+ for child in children['attached']:
315
+ self._extract_images_from_topic_json(child)
316
+
317
+ def _extract_notes_from_topic_json(self, topic):
318
+ """递归从主题中提取备注信息
319
+
320
+ Args:
321
+ topic (dict): 主题对象
322
+ """
323
+ if not topic:
324
+ return
325
+
326
+ # 提取主题ID
327
+ topic_id = topic.get('id', '')
328
+
329
+ # 提取备注
330
+ notes = topic.get('notes', {})
331
+ if notes and 'plain' in notes and notes['plain'].get('content', ''):
332
+ self.notes_dict[topic_id] = notes['plain']['content']
333
+
334
+ # 提取超链接
335
+ href = topic.get('href', '')
336
+ if href:
337
+ self.link_dict[topic_id] = href
338
+
339
+ # 处理子主题
340
+ children = topic.get('children', {})
341
+ if 'attached' in children and isinstance(children['attached'], list):
342
+ for child in children['attached']:
343
+ self._extract_notes_from_topic_json(child)
344
+
345
+ def _get_relative_image_path(self, image_path):
346
+ """获取图片相对于Markdown文件的相对路径
347
+
348
+ Args:
349
+ image_path (str): 图片的绝对路径
350
+
351
+ Returns:
352
+ str: 图片的相对路径
353
+ """
354
+ md_dir = os.path.dirname(os.path.abspath(
355
+ os.path.splitext(self.xmind_file)[0] + '.md'
356
+ ))
357
+ return os.path.relpath(image_path, md_dir)
358
+
359
+ def _parse_topic_json(self, topic, level):
360
+ """递归解析主题及其子主题,转换为Markdown格式
361
+
362
+ Args:
363
+ topic (dict): 主题对象
364
+ level (int): 当前层级深度
365
+ """
366
+ if not topic:
367
+ return
368
+
369
+ # 获取主题ID和标题
370
+ topic_id = topic.get('id', '')
371
+ title = topic.get('title', '')
372
+
373
+ # 处理主题中的换行,将换行转换为Markdown兼容的格式
374
+ # 在Markdown中,需要使用两个空格加换行或空行来表示换行
375
+ title = title.replace('\r\n', '\n') # 统一换行符
376
+ title_lines = title.split('\n')
377
+
378
+ # 检查是否是纯列表格式(每行都以"-"开头)
379
+ is_list_format = all(line.strip().startswith('-') for line in title_lines if line.strip())
380
+
381
+ # 获取第一行内容
382
+ formatted_title = title_lines[0] if title_lines else ""
383
+
384
+ # 如果是列表格式,移除第一行的"-"前缀
385
+ if is_list_format and formatted_title.strip().startswith('-'):
386
+ formatted_title = formatted_title.strip()[1:].strip()
387
+
388
+ # 处理优先级标记
389
+ priority_prefix = ""
390
+ if 'markers' in topic and isinstance(topic['markers'], list):
391
+ for marker in topic['markers']:
392
+ marker_id = marker.get('markerId', '')
393
+ # 优先级映射 - 从XMind标记ID转为Markdown格式
394
+ if marker_id == 'priority-1':
395
+ priority_prefix = "[!(p1)] "
396
+ elif marker_id == 'priority-2':
397
+ priority_prefix = "[!(p2)] "
398
+ elif marker_id == 'priority-3':
399
+ priority_prefix = "[!(p3)] "
400
+
401
+ # 添加到Markdown内容中 - 第一行
402
+ indent = self.indent_style * level
403
+ md_line = f"{indent}- {priority_prefix}{formatted_title}"
404
+
405
+ # 添加链接
406
+ if topic_id in self.link_dict:
407
+ link = self.link_dict[topic_id]
408
+ md_line += f" [{link}]({link})"
409
+
410
+ self.markdown_content.append(md_line)
411
+
412
+ # 处理剩余的行(如果有多行)
413
+ if len(title_lines) > 1:
414
+ additional_indent = self.indent_style * (level + 1)
415
+ for additional_line in title_lines[1:]:
416
+ if not additional_line.strip(): # 跳过空行
417
+ continue
418
+
419
+ # 处理带有"-"前缀的行
420
+ line_content = additional_line.strip()
421
+ if is_list_format and line_content.startswith('-'):
422
+ # 保持列表格式,但增加缩进级别
423
+ line_content = line_content[1:].strip() # 移除"-"符号
424
+ self.markdown_content.append(f"{additional_indent}- {line_content}")
425
+ else:
426
+ # 普通文本行
427
+ self.markdown_content.append(f"{additional_indent}{line_content}")
428
+
429
+ # 添加图片
430
+ if topic_id in self.topic_images:
431
+ for image_id in self.topic_images[topic_id]:
432
+ if image_id in self.image_dict:
433
+ image_path = self.image_dict[image_id]
434
+ rel_path = self._get_relative_image_path(image_path)
435
+ img_indent = self.indent_style * (level + 1)
436
+ self.markdown_content.append(f"{img_indent}![{os.path.basename(image_path)}]({rel_path})")
437
+
438
+ # 添加备注
439
+ if topic_id in self.notes_dict:
440
+ note_content = self.notes_dict[topic_id]
441
+ note_lines = note_content.split('\n')
442
+ note_indent = self.indent_style * (level + 1)
443
+
444
+ # 添加备注块
445
+ self.markdown_content.append(f"{note_indent}- 备注:")
446
+ for note_line in note_lines:
447
+ self.markdown_content.append(f"{note_indent} {note_line}")
448
+
449
+ # 处理子主题
450
+ children = topic.get('children', {})
451
+ if 'attached' in children and isinstance(children['attached'], list):
452
+ for child in children['attached']:
453
+ self._parse_topic_json(child, level + 1)
454
+
455
+ def parse_xml_content(self):
456
+ """解析XMind 8格式的content.xml文件"""
457
+ try:
458
+ tree = ET.parse(self.content_xml)
459
+ root = tree.getroot()
460
+
461
+ # 查找所有sheet元素
462
+ ns = {'xmind': 'urn:xmind:xmap:xmlns:content:2.0'}
463
+ sheets = root.findall('.//xmind:sheet', ns)
464
+
465
+ for sheet_index, sheet in enumerate(sheets):
466
+ # 添加画布标题
467
+ title_elem = sheet.find('./xmind:title', ns)
468
+ sheet_title = title_elem.text if title_elem is not None and title_elem.text else f'画布 {sheet_index + 1}'
469
+ self.markdown_content.append(f"# {sheet_title}\n")
470
+
471
+ # 找到所有普通主题(attached类型)
472
+ main_topic = sheet.find('./xmind:topic', ns)
473
+ if main_topic is not None:
474
+ self._parse_topic_xml(main_topic, 0, ns)
475
+
476
+ # 查找并处理detached类型的主题(独立主题,如"组图生成")
477
+ detached_topics = sheet.findall('.//xmind:topics[@type="detached"]/xmind:topic', ns)
478
+
479
+ # 如果找到了独立主题
480
+ if detached_topics:
481
+ self.markdown_content.append("\n## 其他主题\n")
482
+ for topic in detached_topics:
483
+ title_elem = topic.find('./xmind:title', ns)
484
+ topic_title = title_elem.text if title_elem is not None and title_elem.text else "未命名主题"
485
+ self.markdown_content.append(f"\n### {topic_title}\n")
486
+ self._parse_topic_xml(topic, 0, ns)
487
+
488
+ # 如果不是最后一个画布,添加分隔符
489
+ if sheet_index < len(sheets) - 1:
490
+ self.markdown_content.append("\n---\n")
491
+ except Exception as e:
492
+ raise Exception(f"解析XML内容失败: {str(e)}")
493
+
494
+ def _parse_topic_xml(self, topic, level, ns):
495
+ """递归解析XML主题及其子主题
496
+
497
+ Args:
498
+ topic (Element): XML主题元素
499
+ level (int): 当前层级深度
500
+ ns (dict): 命名空间
501
+ """
502
+ title_elem = topic.find('./xmind:title', ns)
503
+ if title_elem is not None and title_elem.text:
504
+ # 获取主题标题
505
+ title = title_elem.text
506
+
507
+ # 处理主题中的换行,将换行转换为Markdown兼容的格式
508
+ title = title.replace('\r\n', '\n') # 统一换行符
509
+ title_lines = title.split('\n')
510
+
511
+ # 检查是否是纯列表格式(每行都以"-"开头)
512
+ is_list_format = all(line.strip().startswith('-') for line in title_lines if line.strip())
513
+
514
+ # 获取第一行内容
515
+ formatted_title = title_lines[0] if title_lines else ""
516
+
517
+ # 如果是列表格式,移除第一行的"-"前缀
518
+ if is_list_format and formatted_title.strip().startswith('-'):
519
+ formatted_title = formatted_title.strip()[1:].strip()
520
+
521
+ # 处理优先级标记
522
+ priority_prefix = ""
523
+ marker_refs = topic.findall('./xmind:marker-refs/xmind:marker-ref', ns)
524
+ for marker_ref in marker_refs:
525
+ if 'marker-id' in marker_ref.attrib:
526
+ marker_id = marker_ref.attrib['marker-id']
527
+ # XMind 8中优先级标记的ID
528
+ if marker_id == 'priority-1':
529
+ priority_prefix = "[!(p1)] "
530
+ elif marker_id == 'priority-2':
531
+ priority_prefix = "[!(p2)] "
532
+ elif marker_id == 'priority-3':
533
+ priority_prefix = "[!(p3)] "
534
+
535
+ # 添加到Markdown内容中 - 第一行
536
+ indent = self.indent_style * level
537
+ md_line = f"{indent}- {priority_prefix}{formatted_title}"
538
+
539
+ # 检查是否有链接
540
+ link = None
541
+ hyperlink = topic.find('./xmind:hyperlink', ns)
542
+ if hyperlink is not None and 'href' in hyperlink.attrib:
543
+ link = hyperlink.attrib['href']
544
+ md_line += f" [{link}]({link})"
545
+
546
+ self.markdown_content.append(md_line)
547
+
548
+ # 处理剩余的行(如果有多行)
549
+ if len(title_lines) > 1:
550
+ additional_indent = self.indent_style * (level + 1)
551
+ for additional_line in title_lines[1:]:
552
+ if not additional_line.strip(): # 跳过空行
553
+ continue
554
+
555
+ # 处理带有"-"前缀的行
556
+ line_content = additional_line.strip()
557
+ if is_list_format and line_content.startswith('-'):
558
+ # 保持列表格式,但增加缩进级别
559
+ line_content = line_content[1:].strip() # 移除"-"符号
560
+ self.markdown_content.append(f"{additional_indent}- {line_content}")
561
+ else:
562
+ # 普通文本行
563
+ self.markdown_content.append(f"{additional_indent}{line_content}")
564
+
565
+ # 检查是否有图片
566
+ image = topic.find('.//xmind:image', ns)
567
+ if image is not None and 'src' in image.attrib:
568
+ image_src = image.attrib['src']
569
+ image_id = os.path.basename(image_src)
570
+
571
+ if image_id in self.image_dict:
572
+ image_path = self.image_dict[image_id]
573
+ rel_path = self._get_relative_image_path(image_path)
574
+ img_indent = self.indent_style * (level + 1)
575
+ self.markdown_content.append(f"{img_indent}![{os.path.basename(image_path)}]({rel_path})")
576
+
577
+ # 检查是否有备注
578
+ notes = topic.find('./xmind:notes', ns)
579
+ if notes is not None:
580
+ plain = notes.find('.//xmind:plain', ns)
581
+ if plain is not None and plain.text:
582
+ note_content = plain.text
583
+ note_lines = note_content.split('\n')
584
+ note_indent = self.indent_style * (level + 1)
585
+
586
+ # 添加备注块
587
+ self.markdown_content.append(f"{note_indent}- 备注:")
588
+ for note_line in note_lines:
589
+ self.markdown_content.append(f"{note_indent} {note_line}")
590
+
591
+ # 处理子主题
592
+ children = topic.find('./xmind:children/xmind:topics[@type="attached"]', ns)
593
+ if children is not None:
594
+ for child_topic in children.findall('./xmind:topic', ns):
595
+ self._parse_topic_xml(child_topic, level + 1, ns)
596
+
597
+ def convert(self):
598
+ """执行转换过程"""
599
+ try:
600
+ # 提取XMind文件
601
+ format_type = self.extract_xmind()
602
+
603
+ # 提取资源
604
+ self.extract_resources()
605
+
606
+ # 解析内容
607
+ if format_type == 'json':
608
+ self.parse_json_content()
609
+ elif format_type == 'xml':
610
+ self.parse_xml_content()
611
+
612
+ # 在Markdown文档开头添加原始文件信息
613
+ source_info = [
614
+ "<!-- ",
615
+ f"转换自: {os.path.basename(self.xmind_file)}",
616
+ f"转换时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}",
617
+ "原始XMind文件已保留",
618
+ " -->",
619
+ ""
620
+ ]
621
+
622
+ # 将原始文件信息插入到Markdown内容的开头
623
+ self.markdown_content = source_info + self.markdown_content
624
+
625
+ # 返回Markdown内容
626
+ return '\n'.join(self.markdown_content)
627
+
628
+ except Exception as e:
629
+ raise Exception(f"转换失败: {str(e)}")
630
+
631
+ def save_markdown(self, output_file=None):
632
+ """保存Markdown内容到文件
633
+
634
+ Args:
635
+ output_file (str, optional): 输出文件路径。如果未提供,将使用XMind文件名替换扩展名。
636
+
637
+ Returns:
638
+ str: 输出文件路径
639
+ """
640
+ if not output_file:
641
+ base_name = os.path.splitext(self.xmind_file)[0]
642
+ output_file = f"{base_name}.md"
643
+
644
+ try:
645
+ markdown_content = self.convert()
646
+ with open(output_file, 'w', encoding='utf-8') as f:
647
+ f.write(markdown_content)
648
+ return output_file, markdown_content
649
+ except Exception as e:
650
+ raise Exception(f"保存Markdown文件失败: {str(e)}")
651
+
652
+
653
+ def is_valid_xmind_file(file_path):
654
+ """验证文件是否为有效的XMind格式"""
655
+ try:
656
+ # 检查文件是否存在且可读
657
+ if not os.path.exists(file_path) or not os.access(file_path, os.R_OK):
658
+ return False
659
+
660
+ # 检查文件大小
661
+ if os.path.getsize(file_path) < 100:
662
+ return False
663
+
664
+ # 检查是否是有效的zip文件
665
+ with zipfile.ZipFile(file_path, 'r') as zip_ref:
666
+ file_list = zip_ref.namelist()
667
+ return any('content.json' in f for f in file_list) or any('content.xml' in f for f in file_list)
668
+ except Exception:
669
+ return False
670
+
671
+
672
+ def format_error(error, file_name=""):
673
+ """格式化错误信息并提供解决建议"""
674
+ error_text = str(error)
675
+ suggestions = {
676
+ "源文件不存在": "请确认文件存放在正确的位置",
677
+ "没有读取源文件的权限": "请检查文件权限,确保有读取权限",
678
+ "没有写入输出目录的权限": "请检查输出目录权限,确保有写入权限",
679
+ "文件过小": "文件可能不是有效的XMind文件,请检查文件完整性",
680
+ "无效的XMind文件格式": "文件可能已损坏或不是XMind文件,请使用XMind软件检查文件",
681
+ "无法识别的XMind文件结构": "XMind文件格式可能不兼容,尝试用最新版XMind软件保存",
682
+ "内存不足": "关闭其他应用程序释放内存,或者分批次处理大文件",
683
+ "Bad zip file": "无效的XMind文件格式(不是有效的zip文件)",
684
+ "No such file or directory": "找不到文件或目录",
685
+ "Permission denied": "权限被拒绝"
686
+ }
687
+
688
+ # 格式化错误消息
689
+ message = f"失败: {file_name}\n 错误原因: {error_text}"
690
+
691
+ # 添加建议
692
+ for key, suggestion in suggestions.items():
693
+ if key in error_text:
694
+ message += f"\n 解决建议: {suggestion}"
695
+ return message
696
+
697
+ # 默认建议
698
+ message += "\n 解决建议: 请尝试重新运行程序,如果问题持续存在,请考虑重新创建XMind文件"
699
+ return message
700
+
701
+
702
+ def convert_folder(folder_path, output_folder=None, max_workers=4, recursive=False, indent_style=" ", image_dir=None, backup_original=False):
703
+ """转换文件夹中的所有XMind文件为Markdown"""
704
+ if not os.path.isdir(folder_path):
705
+ raise ValueError(f"错误: '{folder_path}' 不是一个有效的文件夹")
706
+
707
+ # 创建必要的目录
708
+ if output_folder and not os.path.exists(output_folder):
709
+ os.makedirs(output_folder)
710
+
711
+ if output_folder and not image_dir:
712
+ image_dir = os.path.join(output_folder, 'images')
713
+ if not os.path.exists(image_dir):
714
+ os.makedirs(image_dir)
715
+
716
+ # 查找并验证XMind文件
717
+ pattern = os.path.join(folder_path, "**" if recursive else "", "*.xmind")
718
+ xmind_files = glob.glob(pattern, recursive=recursive)
719
+ validated_files = [f for f in xmind_files if is_valid_xmind_file(f)]
720
+
721
+ # 处理无效或缺失文件的情况
722
+ if not xmind_files:
723
+ print(f"在 '{folder_path}' 中未找到XMind文件")
724
+ print(f"请将XMind文件放入 '{os.path.abspath(folder_path)}' 文件夹中")
725
+ return (0, 0, 0)
726
+
727
+ invalid_count = len(xmind_files) - len(validated_files)
728
+ if invalid_count > 0:
729
+ print(f"警告: 发现 {invalid_count} 个无效的XMind文件,已跳过")
730
+
731
+ if not validated_files:
732
+ print(f"无有效的XMind文件可转换,请检查文件格式或使用最新版XMind保存")
733
+ return (0, invalid_count, len(xmind_files))
734
+
735
+ # 显示进度函数
736
+ def show_progress(completed, total, success, failed):
737
+ progress = int((completed / total) * 100)
738
+ progress_bar = "=" * int(progress / 2) + ">" + " " * (50 - int(progress / 2))
739
+ sys.stdout.write(f"\r转换进度: [{progress_bar}] {progress}% | 成功: {success} | 失败: {failed}")
740
+ sys.stdout.flush()
741
+
742
+ # 转换单个文件函数
743
+ def convert_single_file(xmind_file):
744
+ base_name = os.path.basename(xmind_file)
745
+ try:
746
+ # 确定输出路径
747
+ if output_folder:
748
+ md_filename = os.path.splitext(base_name)[0] + ".md"
749
+ output_file = os.path.join(output_folder, md_filename)
750
+ file_image_dir = image_dir if image_dir else os.path.join(
751
+ output_folder, 'images', os.path.splitext(base_name)[0]
752
+ )
753
+ if not os.path.exists(file_image_dir):
754
+ os.makedirs(file_image_dir)
755
+ else:
756
+ output_file = None
757
+ file_image_dir = None
758
+
759
+ # 转换文件
760
+ converter = XMindToMarkdown(
761
+ xmind_file,
762
+ indent_style=indent_style,
763
+ image_dir=file_image_dir,
764
+ backup_original=backup_original
765
+ )
766
+ result_path, markdown_content = converter.save_markdown(output_file)
767
+
768
+ # 返回成功结果
769
+ backup_info = f",备份于 {converter.backup_file}" if backup_original and converter.backup_file else ""
770
+ return (xmind_file, True, None, result_path, backup_info)
771
+ except Exception as e:
772
+ return (xmind_file, False, str(e), None, "")
773
+
774
+ # 并行处理文件
775
+ total_files = len(validated_files)
776
+ print(f"找到 {total_files} 个有效的XMind文件")
777
+
778
+ success_count = 0
779
+ failed_count = 0
780
+ completed_count = 0
781
+ failed_files = []
782
+
783
+ show_progress(0, total_files, 0, 0)
784
+
785
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
786
+ futures = {executor.submit(convert_single_file, file): file for file in validated_files}
787
+
788
+ for future in as_completed(futures):
789
+ xmind_file, success, error, output_path, backup_info = future.result()
790
+ completed_count += 1
791
+
792
+ if success:
793
+ success_count += 1
794
+ print(f"\n成功: {os.path.basename(xmind_file)} -> {output_path}")
795
+ print(f" 原始文件保留在 {os.path.dirname(xmind_file)} 目录{backup_info}")
796
+ else:
797
+ failed_count += 1
798
+ failed_files.append((xmind_file, error))
799
+ print(f"\n{format_error(error, os.path.basename(xmind_file))}")
800
+
801
+ show_progress(completed_count, total_files, success_count, failed_count)
802
+
803
+ # 显示结果摘要
804
+ print(f"\n\n转换完成: 总计 {total_files} 个文件, 成功 {success_count} 个, 失败 {failed_count} 个")
805
+ print(f"所有原始XMind文件仍保留在 {os.path.abspath(folder_path)} 目录中")
806
+
807
+ # 如果有失败文件,提供简洁的解决建议
808
+ if failed_files:
809
+ print("\n失败文件汇总及可能解决方法:")
810
+ for i, (failed_file, error) in enumerate(failed_files, 1):
811
+ print(f" {i}. {os.path.basename(failed_file)}")
812
+
813
+ print("\n常见解决方法:")
814
+ print(" • 使用最新版XMind软件重新保存文件")
815
+ print(" • 检查文件读写权限和文件名特殊字符")
816
+ print(" • 对于大文件,尝试关闭其他应用释放内存")
817
+
818
+ return (success_count, failed_count, total_files)
819
+
820
+
821
+ def main():
822
+ """主函数"""
823
+ parser = argparse.ArgumentParser(description='将XMind思维导图转换为Markdown格式')
824
+
825
+ # 设置子命令
826
+ subparsers = parser.add_subparsers(dest='command', help='命令')
827
+
828
+ # 单文件转换命令
829
+ file_parser = subparsers.add_parser('file', help='转换单个XMind文件')
830
+ file_parser.add_argument('input', help='输入的XMind文件路径')
831
+ file_parser.add_argument('-o', '--output', help='输出的Markdown文件路径')
832
+ file_parser.add_argument('--indent', default=' ', help='Markdown列表缩进样式 (默认: 两个空格)')
833
+ file_parser.add_argument('--image-dir', help='指定图片保存目录')
834
+ file_parser.add_argument('--backup', action='store_true', help='创建原始XMind文件的备份')
835
+
836
+ # 文件夹转换命令
837
+ folder_parser = subparsers.add_parser('folder', help='转换文件夹中的所有XMind文件')
838
+ folder_parser.add_argument('input', nargs='?', default='XMIND', help='输入文件夹路径 (默认: XMIND)')
839
+ folder_parser.add_argument('-o', '--output', help='输出文件夹路径 (默认: 与输入文件相同位置)')
840
+ folder_parser.add_argument('-r', '--recursive', action='store_true', help='递归处理子文件夹')
841
+ folder_parser.add_argument('-j', '--jobs', type=int, default=4, help='同时处理的最大文件数 (默认: 4)')
842
+ folder_parser.add_argument('--indent', default=' ', help='Markdown列表缩进样式 (默认: 两个空格)')
843
+ folder_parser.add_argument('--image-dir', help='指定图片保存的总目录')
844
+ folder_parser.add_argument('--backup', action='store_true', help='创建原始XMind文件的备份')
845
+
846
+ # 兼容旧版本的命令行参数
847
+ parser.add_argument('--folder', help='[已弃用] 使用 "folder" 命令替代')
848
+ parser.add_argument('--file', help='[已弃用] 使用 "file" 命令替代')
849
+ parser.add_argument('xmind_file', nargs='?', help='[已弃用] 使用 "file" 命令替代')
850
+ parser.add_argument('md_file', nargs='?', help='[已弃用] 使用 "file" 命令替代')
851
+
852
+ args = parser.parse_args()
853
+
854
+ # 处理旧版本兼容性
855
+ if args.folder:
856
+ print("[警告] --folder 参数已弃用,请使用 'folder' 命令替代")
857
+ args.command = 'folder'
858
+ args.input = args.folder
859
+ args.output = None
860
+ args.recursive = False
861
+ args.jobs = 4
862
+ args.indent = ' '
863
+ args.image_dir = None
864
+ args.backup = False
865
+ elif args.file:
866
+ print("[警告] --file 参数已弃用,请使用 'file' 命令替代")
867
+ args.command = 'file'
868
+ args.input = args.file
869
+ args.output = None
870
+ args.indent = ' '
871
+ args.image_dir = None
872
+ args.backup = False
873
+ elif args.xmind_file:
874
+ print("[警告] 直接传递文件参数的方式已弃用,请使用 'file' 命令替代")
875
+ args.command = 'file'
876
+ args.input = args.xmind_file
877
+ args.output = args.md_file
878
+ args.indent = ' '
879
+ args.image_dir = None
880
+ args.backup = False
881
+
882
+ try:
883
+ # 默认执行批量转换
884
+ if not args.command:
885
+ print("=" * 60)
886
+ print("XMind 批量转换工具")
887
+ print("=" * 60)
888
+ print("将从XMIND文件夹中读取XMind文件并转换为Markdown格式")
889
+ print("生成的Markdown文件将保存到get_markdown文件夹中")
890
+ print("-" * 60)
891
+
892
+ args.command = 'folder'
893
+ args.input = 'XMIND'
894
+ args.output = 'get_markdown'
895
+ args.recursive = False
896
+ args.jobs = 4
897
+ args.indent = ' '
898
+ args.image_dir = None
899
+ args.backup = False
900
+
901
+ # 处理命令
902
+ if args.command == 'file':
903
+ # 检查文件存在
904
+ if not os.path.exists(args.input):
905
+ print(f"错误: 文件 '{args.input}' 不存在")
906
+ return 1
907
+
908
+ # 转换单个文件
909
+ converter = XMindToMarkdown(
910
+ args.input,
911
+ indent_style=args.indent,
912
+ image_dir=args.image_dir,
913
+ backup_original=args.backup
914
+ )
915
+ output_path, markdown_content = converter.save_markdown(args.output)
916
+ print(f"已保存到: {output_path}")
917
+ print(f"图片保存在: {converter.image_dir}")
918
+ print("原始XMind文件已保留" + (f",备份于: {converter.backup_file}" if args.backup else ""))
919
+ return 0
920
+
921
+ elif args.command == 'folder':
922
+ input_folder = args.input
923
+ output_folder = args.output
924
+
925
+ # 创建或检查XMIND文件夹
926
+ if not os.path.exists(input_folder):
927
+ os.makedirs(input_folder)
928
+ print(f"已创建 '{input_folder}' 文件夹,请放入XMind文件后再次运行")
929
+ print(f"文件夹位置: {os.path.abspath(input_folder)}")
930
+ return 0
931
+
932
+ # 检查文件夹是否有XMind文件
933
+ has_xmind = any(f.lower().endswith('.xmind') for f in os.listdir(input_folder))
934
+ if not has_xmind:
935
+ print(f"警告: '{input_folder}' 文件夹中没有XMind文件")
936
+ print(f"请将XMind文件放入 '{os.path.abspath(input_folder)}' 后再次运行")
937
+ return 0
938
+
939
+ # 创建输出文件夹
940
+ if output_folder and not os.path.exists(output_folder):
941
+ os.makedirs(output_folder)
942
+
943
+ # 执行转换
944
+ start_time = time.time()
945
+ success_count, failed_count, total_files = convert_folder(
946
+ input_folder,
947
+ output_folder,
948
+ args.jobs,
949
+ args.recursive,
950
+ args.indent,
951
+ args.image_dir,
952
+ args.backup
953
+ )
954
+ end_time = time.time()
955
+
956
+ print(f"总耗时: {end_time - start_time:.2f} 秒")
957
+
958
+ # 检查源文件完整性
959
+ if success_count > 0:
960
+ xmind_files = glob.glob(os.path.join(input_folder, "*.xmind"))
961
+ if all(os.path.exists(f) for f in xmind_files):
962
+ print(f"所有源文件仍保留在 {input_folder} 中")
963
+
964
+ return 0 if failed_count == 0 else 1
965
+
966
+ else:
967
+ parser.print_help()
968
+ return 1
969
+
970
+ except Exception as e:
971
+ print(f"错误: {str(e)}")
972
+ return 1
973
+
974
+
975
+ if __name__ == "__main__":
976
+ sys.exit(main())