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.
@@ -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).
@@ -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