xmindpy 0.1.0__tar.gz
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.
- xmindpy-0.1.0/.gitignore +26 -0
- xmindpy-0.1.0/LICENSE +26 -0
- xmindpy-0.1.0/PKG-INFO +99 -0
- xmindpy-0.1.0/README.md +79 -0
- xmindpy-0.1.0/pyproject.toml +47 -0
- xmindpy-0.1.0/xmindpy/__init__.py +8 -0
- xmindpy-0.1.0/xmindpy/convert.py +92 -0
- xmindpy-0.1.0/xmindpy/loader.py +64 -0
- xmindpy-0.1.0/xmindpy/saver.py +70 -0
- xmindpy-0.1.0/xmindpy/workbook.py +180 -0
xmindpy-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
env/
|
|
10
|
+
build/
|
|
11
|
+
dist/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
*.egg
|
|
14
|
+
|
|
15
|
+
# Test / coverage
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.coverage
|
|
18
|
+
htmlcov/
|
|
19
|
+
|
|
20
|
+
# uv
|
|
21
|
+
.uv-cache/
|
|
22
|
+
|
|
23
|
+
# IDE
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
26
|
+
.DS_Store
|
xmindpy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Funnlink
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This project is a rewrite of https://github.com/zhuifengshen/xmind (MIT licensed).
|
|
26
|
+
Original work Copyright (c) 2018 Devin (1324556701@qq.com).
|
xmindpy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: xmindpy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Modern Python SDK for XMind 2020+ JSON format. A clean rewrite of zhuifengshen/xmind for the new file format.
|
|
5
|
+
Project-URL: Homepage, https://github.com/innotools/xmindpy
|
|
6
|
+
Project-URL: Repository, https://github.com/innotools/xmindpy
|
|
7
|
+
Project-URL: Issues, https://github.com/innotools/xmindpy/issues
|
|
8
|
+
Author-email: Funnlink <edward@innoshop.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: json,mind-map,xmind,xmind-2020,思维导图
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# xmindpy
|
|
22
|
+
|
|
23
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
24
|
+
|
|
25
|
+
Modern Python SDK for creating, reading, and editing **XMind 2020+** mind map files (JSON format).
|
|
26
|
+
|
|
27
|
+
This is a clean rewrite of [zhuifengshen/xmind](https://github.com/zhuifengshen/xmind) — the original library only supports the legacy XMind 2.0 XML format and **cannot produce files that XMind 2020/2022/2024 can open**. This fork replaces the XML DOM core with native Python objects and emits the JSON-based file format that modern XMind expects.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install xmindpy
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from xmindpy import Workbook, Topic
|
|
39
|
+
|
|
40
|
+
wb = Workbook()
|
|
41
|
+
sheet = wb.create_sheet("Project Plan")
|
|
42
|
+
root = Topic("Project Plan")
|
|
43
|
+
root.add(Topic("Research"))
|
|
44
|
+
root.add(Topic("Design"))
|
|
45
|
+
root.add(Topic("Build"))
|
|
46
|
+
sheet.root_topic = root
|
|
47
|
+
|
|
48
|
+
wb.save("plan.xmind")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Open `plan.xmind` in XMind 2020 or later — it just works.
|
|
52
|
+
|
|
53
|
+
## Loading
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from xmindpy import load_workbook
|
|
57
|
+
|
|
58
|
+
wb = load_workbook("plan.xmind")
|
|
59
|
+
for sheet in wb.sheets:
|
|
60
|
+
print(sheet.title)
|
|
61
|
+
print(sheet.root_topic.title)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Convert legacy `.xmind` files (XML format)
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from xmindpy import convert
|
|
68
|
+
convert.to_json("legacy.xmind", "modern.xmind")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
| Class | Purpose |
|
|
74
|
+
|-------|---------|
|
|
75
|
+
| `Workbook` | Container for one or more sheets |
|
|
76
|
+
| `Sheet` | One mind map (tab) in the workbook |
|
|
77
|
+
| `Topic` | A node in the mind map; has children, notes, labels, markers |
|
|
78
|
+
| `load_workbook(path)` | Load an `.xmind` file (auto-detects JSON or legacy XML format) |
|
|
79
|
+
| `convert.to_json(src, dst)` | Migrate a legacy XML-format `.xmind` to modern JSON format |
|
|
80
|
+
|
|
81
|
+
## File format
|
|
82
|
+
|
|
83
|
+
The generated `.xmind` is a ZIP with:
|
|
84
|
+
|
|
85
|
+
| File | Purpose |
|
|
86
|
+
|------|---------|
|
|
87
|
+
| `mimetype` | Always `application/vnd.xmind.workbook` |
|
|
88
|
+
| `content.json` | Top-level array of sheets |
|
|
89
|
+
| `metadata.json` | `dataStructureVersion: "3"`, `layoutEngineVersion: "5"` |
|
|
90
|
+
| `manifest.json` | File-entry registry (`file-entries` object, `media-type` kebab-case) |
|
|
91
|
+
| `Thumbnails/thumbnail.png` | 1×1 transparent PNG (required by some parsers) |
|
|
92
|
+
|
|
93
|
+
This matches the format documented at the [XMind Wiki](https://github.com/xmindltd/xmind/wiki/XMindFileFormat).
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT — see [LICENSE](LICENSE).
|
|
98
|
+
|
|
99
|
+
Forked from [zhuifengshen/xmind](https://github.com/zhuifengshen/xmind) by Devin (MIT).
|
xmindpy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# xmindpy
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
Modern Python SDK for creating, reading, and editing **XMind 2020+** mind map files (JSON format).
|
|
6
|
+
|
|
7
|
+
This is a clean rewrite of [zhuifengshen/xmind](https://github.com/zhuifengshen/xmind) — the original library only supports the legacy XMind 2.0 XML format and **cannot produce files that XMind 2020/2022/2024 can open**. This fork replaces the XML DOM core with native Python objects and emits the JSON-based file format that modern XMind expects.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install xmindpy
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from xmindpy import Workbook, Topic
|
|
19
|
+
|
|
20
|
+
wb = Workbook()
|
|
21
|
+
sheet = wb.create_sheet("Project Plan")
|
|
22
|
+
root = Topic("Project Plan")
|
|
23
|
+
root.add(Topic("Research"))
|
|
24
|
+
root.add(Topic("Design"))
|
|
25
|
+
root.add(Topic("Build"))
|
|
26
|
+
sheet.root_topic = root
|
|
27
|
+
|
|
28
|
+
wb.save("plan.xmind")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Open `plan.xmind` in XMind 2020 or later — it just works.
|
|
32
|
+
|
|
33
|
+
## Loading
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from xmindpy import load_workbook
|
|
37
|
+
|
|
38
|
+
wb = load_workbook("plan.xmind")
|
|
39
|
+
for sheet in wb.sheets:
|
|
40
|
+
print(sheet.title)
|
|
41
|
+
print(sheet.root_topic.title)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Convert legacy `.xmind` files (XML format)
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from xmindpy import convert
|
|
48
|
+
convert.to_json("legacy.xmind", "modern.xmind")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
| Class | Purpose |
|
|
54
|
+
|-------|---------|
|
|
55
|
+
| `Workbook` | Container for one or more sheets |
|
|
56
|
+
| `Sheet` | One mind map (tab) in the workbook |
|
|
57
|
+
| `Topic` | A node in the mind map; has children, notes, labels, markers |
|
|
58
|
+
| `load_workbook(path)` | Load an `.xmind` file (auto-detects JSON or legacy XML format) |
|
|
59
|
+
| `convert.to_json(src, dst)` | Migrate a legacy XML-format `.xmind` to modern JSON format |
|
|
60
|
+
|
|
61
|
+
## File format
|
|
62
|
+
|
|
63
|
+
The generated `.xmind` is a ZIP with:
|
|
64
|
+
|
|
65
|
+
| File | Purpose |
|
|
66
|
+
|------|---------|
|
|
67
|
+
| `mimetype` | Always `application/vnd.xmind.workbook` |
|
|
68
|
+
| `content.json` | Top-level array of sheets |
|
|
69
|
+
| `metadata.json` | `dataStructureVersion: "3"`, `layoutEngineVersion: "5"` |
|
|
70
|
+
| `manifest.json` | File-entry registry (`file-entries` object, `media-type` kebab-case) |
|
|
71
|
+
| `Thumbnails/thumbnail.png` | 1×1 transparent PNG (required by some parsers) |
|
|
72
|
+
|
|
73
|
+
This matches the format documented at the [XMind Wiki](https://github.com/xmindltd/xmind/wiki/XMindFileFormat).
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT — see [LICENSE](LICENSE).
|
|
78
|
+
|
|
79
|
+
Forked from [zhuifengshen/xmind](https://github.com/zhuifengshen/xmind) by Devin (MIT).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xmindpy"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Modern Python SDK for XMind 2020+ JSON format. A clean rewrite of zhuifengshen/xmind for the new file format."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Funnlink", email = "edward@innoshop.com"},
|
|
10
|
+
]
|
|
11
|
+
keywords = ["xmind", "mind-map", "思维导图", "json", "xmind-2020"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/innotools/xmindpy"
|
|
24
|
+
Repository = "https://github.com/innotools/xmindpy"
|
|
25
|
+
Issues = "https://github.com/innotools/xmindpy/issues"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["xmindpy"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["xmindpy", "README.md", "LICENSE"]
|
|
36
|
+
|
|
37
|
+
[tool.uv]
|
|
38
|
+
package = true
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
pythonpath = ["."]
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=9.1.1",
|
|
47
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""xmindpy — modern Python SDK for XMind 2020+ JSON format."""
|
|
2
|
+
|
|
3
|
+
from .workbook import Workbook, Sheet, Topic
|
|
4
|
+
from .loader import load_workbook
|
|
5
|
+
from . import convert
|
|
6
|
+
|
|
7
|
+
__all__ = ["Workbook", "Sheet", "Topic", "load_workbook", "convert"]
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Convert legacy XMind 2.0 XML-format `.xmind` files to the modern JSON format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import zipfile
|
|
7
|
+
from xml.etree import ElementTree as ET
|
|
8
|
+
|
|
9
|
+
from .workbook import Workbook, Sheet, Topic
|
|
10
|
+
from .saver import save_workbook
|
|
11
|
+
|
|
12
|
+
XML_NS = "{urn:xmind:xmap:xmlns:content:2.0}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_json(src_path: str, dst_path: str) -> Workbook:
|
|
16
|
+
"""Read ``src_path`` (legacy XML) and save it as ``dst_path`` (modern JSON).
|
|
17
|
+
|
|
18
|
+
Returns the migrated :class:`Workbook` so the caller can inspect it.
|
|
19
|
+
"""
|
|
20
|
+
workbook = _read_legacy_xml(src_path)
|
|
21
|
+
save_workbook(workbook, dst_path)
|
|
22
|
+
return workbook
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_legacy_xml(path: str) -> Workbook:
|
|
26
|
+
"""Best-effort XML→JSON parser for the XMind 2.0 schema.
|
|
27
|
+
|
|
28
|
+
Supports title, nested ``topic`` children, and ``notes/plain``. Other fields
|
|
29
|
+
(markers, labels, relationships, hyperlinks) are best-effort: when present
|
|
30
|
+
in the XML they are preserved; otherwise they are dropped.
|
|
31
|
+
"""
|
|
32
|
+
with zipfile.ZipFile(path, "r") as z:
|
|
33
|
+
with z.open("content.xml") as f:
|
|
34
|
+
tree = ET.parse(f)
|
|
35
|
+
root = tree.getroot()
|
|
36
|
+
|
|
37
|
+
workbook = Workbook()
|
|
38
|
+
for sheet_el in root.findall(f"{XML_NS}sheet"):
|
|
39
|
+
title_el = sheet_el.find(f"{XML_NS}title")
|
|
40
|
+
title = title_el.text if title_el is not None else "Sheet"
|
|
41
|
+
topic_el = sheet_el.find(f"{XML_NS}topic")
|
|
42
|
+
root_topic = _topic_from_xml(topic_el) if topic_el is not None else Topic(title=title)
|
|
43
|
+
sheet = Sheet(title=title, root_topic=root_topic)
|
|
44
|
+
workbook.sheets.append(sheet)
|
|
45
|
+
return workbook
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _topic_from_xml(topic_el: ET.Element) -> Topic:
|
|
49
|
+
title_el = topic_el.find(f"{XML_NS}title")
|
|
50
|
+
title = title_el.text if title_el is not None else ""
|
|
51
|
+
|
|
52
|
+
topic = Topic(title=title or "", id=topic_el.get("id", ""))
|
|
53
|
+
|
|
54
|
+
children_el = topic_el.find(f"{XML_NS}children")
|
|
55
|
+
if children_el is not None:
|
|
56
|
+
topics_el = children_el.find(f"{XML_NS}topics")
|
|
57
|
+
if topics_el is not None:
|
|
58
|
+
for child_el in topics_el.findall(f"{XML_NS}topic"):
|
|
59
|
+
topic.children.append(_topic_from_xml(child_el))
|
|
60
|
+
|
|
61
|
+
notes_el = topic_el.find(f"{XML_NS}notes")
|
|
62
|
+
if notes_el is not None:
|
|
63
|
+
plain_el = notes_el.find(f"{XML_NS}plain")
|
|
64
|
+
if plain_el is not None and plain_el.text:
|
|
65
|
+
topic.notes = plain_el.text
|
|
66
|
+
|
|
67
|
+
markers_el = topic_el.find(f"{XML_NS}marker-refs")
|
|
68
|
+
if markers_el is not None:
|
|
69
|
+
for marker_el in markers_el.findall(f"{XML_NS}marker-ref"):
|
|
70
|
+
mid = marker_el.get("marker-id")
|
|
71
|
+
if mid:
|
|
72
|
+
topic.markers.append(mid)
|
|
73
|
+
|
|
74
|
+
labels_el = topic_el.find(f"{XML_NS}labels")
|
|
75
|
+
if labels_el is not None:
|
|
76
|
+
for label_el in labels_el.findall(f"{XML_NS}label"):
|
|
77
|
+
if label_el.text:
|
|
78
|
+
topic.labels.append(label_el.text)
|
|
79
|
+
|
|
80
|
+
return topic
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def to_legacy_xml(src_path: str, dst_path: str) -> None:
|
|
84
|
+
"""Inverse of :func:`to_json`: write the workbook back as legacy XML.
|
|
85
|
+
|
|
86
|
+
Useful if you still need to open the file with very old XMind builds (<2018)
|
|
87
|
+
that predate the JSON format.
|
|
88
|
+
"""
|
|
89
|
+
raise NotImplementedError(
|
|
90
|
+
"Round-tripping back to legacy XML is not implemented. "
|
|
91
|
+
"Modern XMind (>= 2018) supports the JSON format produced by save_workbook()."
|
|
92
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Load an XMind workbook from a `.xmind` file (auto-detects JSON or legacy XML)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import zipfile
|
|
7
|
+
from typing import IO
|
|
8
|
+
|
|
9
|
+
from .workbook import Workbook
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _is_json_format(names: list[str]) -> bool:
|
|
13
|
+
return "content.json" in names
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_xml_format(names: list[str]) -> bool:
|
|
17
|
+
return "content.xml" in names
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_workbook(path: str) -> Workbook:
|
|
21
|
+
"""Load an `.xmind` file. Supports modern JSON format and legacy XML."""
|
|
22
|
+
with zipfile.ZipFile(path, "r") as z:
|
|
23
|
+
names = z.namelist()
|
|
24
|
+
if _is_json_format(names):
|
|
25
|
+
return _load_json(z)
|
|
26
|
+
if _is_xml_format(names):
|
|
27
|
+
raise UnsupportedFormatError(
|
|
28
|
+
"Detected legacy XMind 2.0 XML format. "
|
|
29
|
+
"Use xmindpy.convert.to_json() to migrate first."
|
|
30
|
+
)
|
|
31
|
+
raise ValueError(f"Unrecognized .xmind archive (members: {names[:5]})")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_from_stream(stream: IO[bytes]) -> Workbook:
|
|
35
|
+
import io
|
|
36
|
+
|
|
37
|
+
with zipfile.ZipFile(io.BytesIO(stream.read()), "r") as z:
|
|
38
|
+
names = z.namelist()
|
|
39
|
+
if _is_json_format(names):
|
|
40
|
+
return _load_json(z)
|
|
41
|
+
if _is_xml_format(names):
|
|
42
|
+
raise UnsupportedFormatError(
|
|
43
|
+
"Detected legacy XMind 2.0 XML format. "
|
|
44
|
+
"Use xmindpy.convert.to_json() to migrate first."
|
|
45
|
+
)
|
|
46
|
+
raise ValueError(f"Unrecognized .xmind archive (members: {names[:5]})")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_json(z: zipfile.ZipFile) -> Workbook:
|
|
50
|
+
content_bytes = z.read("content.json")
|
|
51
|
+
content = json.loads(content_bytes.decode("utf-8"))
|
|
52
|
+
if not isinstance(content, list):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Invalid content.json: expected array of sheets, got {type(content).__name__}"
|
|
55
|
+
)
|
|
56
|
+
workbook = Workbook.from_content(content)
|
|
57
|
+
if "metadata.json" in z.namelist():
|
|
58
|
+
metadata = json.loads(z.read("metadata.json").decode("utf-8"))
|
|
59
|
+
workbook.metadata = metadata
|
|
60
|
+
return workbook
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UnsupportedFormatError(Exception):
|
|
64
|
+
"""Raised when the input file uses a format this library does not parse directly."""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Save a Workbook as a modern XMind 2020+ JSON-format `.xmind` file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import zipfile
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import IO, Any
|
|
9
|
+
|
|
10
|
+
from .workbook import Workbook
|
|
11
|
+
|
|
12
|
+
MIMETYPE = "application/vnd.xmind.workbook"
|
|
13
|
+
|
|
14
|
+
# 1x1 transparent PNG; some XMind builds require a thumbnail to render preview.
|
|
15
|
+
THUMBNAIL_PNG: bytes = bytes.fromhex(
|
|
16
|
+
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
|
|
17
|
+
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
|
|
18
|
+
"ae426082"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _build_metadata(workbook: Workbook) -> dict[str, Any]:
|
|
23
|
+
md = {
|
|
24
|
+
"modifier": workbook.metadata.get("modifier", ""),
|
|
25
|
+
"dataStructureVersion": "3",
|
|
26
|
+
"creator": workbook.metadata.get("creator") or {"name": "xmindpy"},
|
|
27
|
+
"layoutEngineVersion": "5",
|
|
28
|
+
}
|
|
29
|
+
if workbook.sheets:
|
|
30
|
+
md["activeSheetId"] = workbook.sheets[0].id
|
|
31
|
+
md["created"] = datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
|
32
|
+
return md
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_manifest(file_entries: dict[str, str]) -> str:
|
|
36
|
+
entries = {path: {"media-type": media} for path, media in file_entries.items()}
|
|
37
|
+
return json.dumps({"file-entries": entries}, ensure_ascii=False, indent=2)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_workbook(workbook: Workbook, path: str) -> None:
|
|
41
|
+
"""Save ``workbook`` to ``path`` in modern XMind JSON format."""
|
|
42
|
+
content_json = json.dumps(workbook.to_content(), ensure_ascii=False, indent=2)
|
|
43
|
+
metadata_json = json.dumps(_build_metadata(workbook), ensure_ascii=False, indent=2)
|
|
44
|
+
|
|
45
|
+
# manifest lists every file we actually put in the zip; compute media-types.
|
|
46
|
+
manifest_data = _build_manifest({
|
|
47
|
+
"content.json": "application/json",
|
|
48
|
+
"metadata.json": "application/json",
|
|
49
|
+
"Thumbnails/thumbnail.png": "image/png",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
|
|
53
|
+
# mimetype MUST be the first entry, stored uncompressed
|
|
54
|
+
mimetype_info = zipfile.ZipInfo("mimetype")
|
|
55
|
+
mimetype_info.compress_type = zipfile.ZIP_STORED
|
|
56
|
+
z.writestr(mimetype_info, MIMETYPE)
|
|
57
|
+
|
|
58
|
+
z.writestr("content.json", content_json)
|
|
59
|
+
z.writestr("metadata.json", metadata_json)
|
|
60
|
+
z.writestr("manifest.json", manifest_data)
|
|
61
|
+
z.writestr("Thumbnails/thumbnail.png", THUMBNAIL_PNG)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_to_stream(workbook: Workbook, stream: IO[bytes]) -> None:
|
|
65
|
+
"""Like :func:`save_workbook` but writes to a binary stream."""
|
|
66
|
+
import io
|
|
67
|
+
|
|
68
|
+
buf = io.BytesIO()
|
|
69
|
+
save_workbook(workbook, buf)
|
|
70
|
+
stream.write(buf.getvalue())
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Native Python object model for an XMind workbook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _new_id() -> str:
|
|
11
|
+
return str(uuid.uuid4())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Fields we know how to round-trip; everything else lands in ``_extras``.
|
|
15
|
+
_KNOWN_TOPIC_KEYS = frozenset({
|
|
16
|
+
"id", "class", "title", "styleId", "labels", "markers", "notes",
|
|
17
|
+
"hyperlinks", "children", "detached",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Topic:
|
|
23
|
+
"""A node in the mind map.
|
|
24
|
+
|
|
25
|
+
Children are stored on ``self.children`` as a list of ``Topic`` instances.
|
|
26
|
+
Detached floating topics go on ``self.detached`` (rare; mirrors XMind's
|
|
27
|
+
"free" topic positioning).
|
|
28
|
+
|
|
29
|
+
Unrecognized JSON fields (e.g. ``callout``, ``summary``, ``boundary``,
|
|
30
|
+
image references) loaded from a file are preserved in ``self._extras``
|
|
31
|
+
and re-emitted on save, so the library is forward-compatible with
|
|
32
|
+
XMind features we don't model explicitly.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
title: str
|
|
36
|
+
id: str = field(default_factory=_new_id)
|
|
37
|
+
children: list[Topic] = field(default_factory=list)
|
|
38
|
+
detached: list[Topic] = field(default_factory=list)
|
|
39
|
+
notes: str | None = None
|
|
40
|
+
labels: list[str] = field(default_factory=list)
|
|
41
|
+
markers: list[str] = field(default_factory=list)
|
|
42
|
+
style_id: str | None = None
|
|
43
|
+
hyperlinks: list[str] = field(default_factory=list)
|
|
44
|
+
_extras: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def add(self, topic: Topic) -> Topic:
|
|
47
|
+
self.children.append(topic)
|
|
48
|
+
return topic
|
|
49
|
+
|
|
50
|
+
def add_detached(self, topic: Topic) -> Topic:
|
|
51
|
+
self.detached.append(topic)
|
|
52
|
+
return topic
|
|
53
|
+
|
|
54
|
+
# --- serialization helpers --------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
data: dict[str, Any] = {
|
|
58
|
+
"id": self.id,
|
|
59
|
+
"class": "topic",
|
|
60
|
+
"title": self.title,
|
|
61
|
+
}
|
|
62
|
+
if self.style_id:
|
|
63
|
+
data["styleId"] = self.style_id
|
|
64
|
+
if self.labels:
|
|
65
|
+
data["labels"] = list(self.labels)
|
|
66
|
+
if self.markers:
|
|
67
|
+
data["markers"] = [{"markerId": m} for m in self.markers]
|
|
68
|
+
if self.notes:
|
|
69
|
+
data["notes"] = {"plain": {"content": self.notes}}
|
|
70
|
+
if self.hyperlinks:
|
|
71
|
+
data["hyperlinks"] = [
|
|
72
|
+
{"href": h, "description": h} for h in self.hyperlinks
|
|
73
|
+
]
|
|
74
|
+
if self.children:
|
|
75
|
+
data["children"] = {"attached": [c.to_dict() for c in self.children]}
|
|
76
|
+
if self.detached:
|
|
77
|
+
data["detached"] = [t.to_dict() for t in self.detached]
|
|
78
|
+
# Preserve any unrecognized fields as-is (callouts, summaries, etc.)
|
|
79
|
+
data.update(self._extras)
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_dict(cls, data: dict[str, Any]) -> Topic:
|
|
84
|
+
topic = cls(
|
|
85
|
+
title=data.get("title", ""),
|
|
86
|
+
id=data.get("id") or _new_id(),
|
|
87
|
+
)
|
|
88
|
+
if "styleId" in data:
|
|
89
|
+
topic.style_id = data["styleId"]
|
|
90
|
+
if "labels" in data:
|
|
91
|
+
topic.labels = list(data["labels"])
|
|
92
|
+
if "markers" in data:
|
|
93
|
+
topic.markers = [m["markerId"] for m in data["markers"] if "markerId" in m]
|
|
94
|
+
if "notes" in data:
|
|
95
|
+
plain = data["notes"].get("plain", {})
|
|
96
|
+
topic.notes = plain.get("content")
|
|
97
|
+
if "hyperlinks" in data:
|
|
98
|
+
topic.hyperlinks = [h.get("href", "") for h in data["hyperlinks"]]
|
|
99
|
+
children = data.get("children", {}).get("attached", [])
|
|
100
|
+
topic.children = [cls.from_dict(c) for c in children]
|
|
101
|
+
detached = data.get("detached", [])
|
|
102
|
+
topic.detached = [cls.from_dict(t) for t in detached]
|
|
103
|
+
# Save any unknown keys so re-save doesn't drop them.
|
|
104
|
+
topic._extras = {k: v for k, v in data.items() if k not in _KNOWN_TOPIC_KEYS}
|
|
105
|
+
return topic
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Same pattern for Sheet: keep unknown fields round-trippable.
|
|
109
|
+
_KNOWN_SHEET_KEYS = frozenset({
|
|
110
|
+
"id", "class", "title", "rootTopic", "relationships",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Sheet:
|
|
116
|
+
"""One tab / mind map in the workbook.
|
|
117
|
+
|
|
118
|
+
Unrecognized JSON fields are preserved in ``self._extras`` so the library
|
|
119
|
+
is forward-compatible with XMind features we don't model explicitly.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
title: str
|
|
123
|
+
root_topic: Topic
|
|
124
|
+
id: str = field(default_factory=_new_id)
|
|
125
|
+
relationships: list[dict[str, Any]] = field(default_factory=list)
|
|
126
|
+
_extras: dict[str, Any] = field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> dict[str, Any]:
|
|
129
|
+
data: dict[str, Any] = {
|
|
130
|
+
"id": self.id,
|
|
131
|
+
"class": "sheet",
|
|
132
|
+
"title": self.title,
|
|
133
|
+
"rootTopic": self.root_topic.to_dict(),
|
|
134
|
+
"relationships": list(self.relationships),
|
|
135
|
+
}
|
|
136
|
+
data.update(self._extras)
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: dict[str, Any]) -> Sheet:
|
|
141
|
+
root = Topic.from_dict(data["rootTopic"])
|
|
142
|
+
sheet = cls(
|
|
143
|
+
title=data.get("title", "Sheet"),
|
|
144
|
+
root_topic=root,
|
|
145
|
+
id=data.get("id") or _new_id(),
|
|
146
|
+
relationships=list(data.get("relationships", [])),
|
|
147
|
+
)
|
|
148
|
+
sheet._extras = {k: v for k, v in data.items() if k not in _KNOWN_SHEET_KEYS}
|
|
149
|
+
return sheet
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class Workbook:
|
|
154
|
+
"""Container of one or more sheets."""
|
|
155
|
+
|
|
156
|
+
sheets: list[Sheet] = field(default_factory=list)
|
|
157
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
158
|
+
|
|
159
|
+
def create_sheet(self, title: str = "Sheet") -> Sheet:
|
|
160
|
+
sheet = Sheet(
|
|
161
|
+
title=title,
|
|
162
|
+
root_topic=Topic(title=title),
|
|
163
|
+
)
|
|
164
|
+
self.sheets.append(sheet)
|
|
165
|
+
return sheet
|
|
166
|
+
|
|
167
|
+
def to_content(self) -> list[dict[str, Any]]:
|
|
168
|
+
return [s.to_dict() for s in self.sheets]
|
|
169
|
+
|
|
170
|
+
def save(self, path: str) -> None:
|
|
171
|
+
"""Save to a file in modern XMind JSON format."""
|
|
172
|
+
from .saver import save_workbook
|
|
173
|
+
save_workbook(self, path)
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_content(cls, content: list[dict[str, Any]]) -> Workbook:
|
|
177
|
+
wb = cls()
|
|
178
|
+
for sheet_data in content:
|
|
179
|
+
wb.sheets.append(Sheet.from_dict(sheet_data))
|
|
180
|
+
return wb
|