|
#!/usr/bin/env python3 |
|
""" |
|
Convert iThoughts .itmz mind map to Mermaid mindmap format. |
|
|
|
Usage: |
|
python ithoughts_to_mermaid.py <path-to-file.itmz> |
|
python ithoughts_to_mermaid.py <path-to-file.itmz> -o output.md |
|
|
|
Output is written to stdout or the specified file. |
|
""" |
|
|
|
import argparse |
|
import re |
|
import sys |
|
import xml.etree.ElementTree as ET |
|
import zipfile |
|
from pathlib import Path |
|
|
|
|
|
# topicShape → Mermaid mindmap node delimiters |
|
# 0=rounded, 1=oval, 2=rectangle, 3=hexagon |
|
SHAPE_DELIMITERS = { |
|
0: ("(", ")"), # rounded (text) |
|
1: ("((", "))"), # oval/circle ((text)) |
|
2: ("[", "]"), # square [text] |
|
3: ("{{", "}}"), # hexagon {{text}} |
|
} |
|
DEFAULT_SHAPE = (None, None) # no delimiters = default node |
|
|
|
# Trailing typo patterns: delimiter + stray backtick or vice versa (e.g. "Editor`)") |
|
_TYPO_SUFFIXES = (")`", "]`", "}`", "`)", "`]", "`}") |
|
|
|
|
|
def sanitize_text(text: str) -> str: |
|
"""Remove common typos from source map labels that would break Mermaid parsing.""" |
|
s = text.strip() |
|
# Strip stray leading/trailing backticks |
|
s = s.strip("`") |
|
# Remove typo suffixes (e.g. "Editor`)" or "Item)`") |
|
changed = True |
|
while changed and s: |
|
changed = False |
|
for suffix in _TYPO_SUFFIXES: |
|
if s.endswith(suffix): |
|
s = s[: -len(suffix)].rstrip() |
|
changed = True |
|
break |
|
# Strip lone trailing ) ] } when unbalanced (common typo) |
|
for open_c, close_c in [("(", ")"), ("[", "]"), ("{", "}")]: |
|
while s.endswith(close_c) and s.count(open_c) < s.count(close_c): |
|
s = s[:-1].rstrip() |
|
# Collapse multiple spaces |
|
s = re.sub(r" +", " ", s) |
|
return s |
|
|
|
|
|
def parse_style(zf) -> dict[int, int]: |
|
"""Parse style.xml, return level n -> topicShape.""" |
|
shapes = {} |
|
try: |
|
with zf.open("style.xml") as f: |
|
content = f.read().decode("utf-8", errors="replace") |
|
except KeyError: |
|
return shapes |
|
root = ET.fromstring(content) |
|
for level in root.findall(".//level"): |
|
n = level.get("n") |
|
ts = level.get("topicShape") |
|
if n is not None and ts is not None: |
|
try: |
|
shapes[int(n)] = int(ts) |
|
except ValueError: |
|
pass |
|
return shapes |
|
|
|
|
|
_BRACKET_CHARS = frozenset("(){}[]") |
|
|
|
|
|
def wrap_text(text: str, shape_key: int, shape_map: dict) -> str: |
|
"""Apply Mermaid shape delimiters to node text. If content contains any |
|
bracket chars ( ) { } [ ], wrap the content in double quotes.""" |
|
shape = shape_map.get(shape_key, 0) |
|
left, right = SHAPE_DELIMITERS.get(shape, DEFAULT_SHAPE) |
|
content = text |
|
if any(c in text for c in _BRACKET_CHARS): |
|
# Double the quote to escape ("" not \") |
|
content = '"' + text.replace('"', '""') + '"' |
|
if left and right: |
|
return f"{left}{content}{right}" |
|
return content |
|
|
|
|
|
def escape_mermaid(text: str) -> str: |
|
"""Escape text for Mermaid mindmap (no shape delimiters).""" |
|
return ( |
|
text.replace("]", "\\]") |
|
.replace("[", "\\[") |
|
.replace("(", "\\(") |
|
.replace(")", "\\)") |
|
.replace("{", "\\{") |
|
.replace("}", "\\}") |
|
.replace('"', '\\"') |
|
) |
|
|
|
|
|
def topic_to_mermaid( |
|
topic: ET.Element, |
|
shapes: dict[int, int], |
|
level: int, |
|
indent: str, |
|
out: list[str], |
|
) -> None: |
|
text_attr = sanitize_text(topic.get("text", "")) |
|
if not text_attr: |
|
text_attr = "(unnamed)" |
|
shape_key = min(level, max(shapes.keys(), default=0)) if shapes else 0 |
|
if shapes: |
|
# Use shape for this level |
|
wrapped = wrap_text(text_attr, shape_key, shapes) |
|
else: |
|
wrapped = escape_mermaid(text_attr) |
|
out.append(f"{indent}{wrapped}") |
|
child_indent = indent + " " |
|
for child in topic: |
|
if child.tag == "topic": |
|
topic_to_mermaid(child, shapes, level + 1, child_indent, out) |
|
|
|
|
|
def extract_root_topic(root: ET.Element) -> ET.Element | None: |
|
"""Get the root topic. mapdata has <topics> (inside <iThoughts> or at root).""" |
|
topics = root.find("topics") |
|
if topics is None: |
|
topics = root |
|
for c in topics: |
|
if c.tag == "topic": |
|
return c |
|
return None |
|
|
|
|
|
def convert(itmz_path: str) -> str: |
|
lines = ["mindmap"] |
|
with zipfile.ZipFile(itmz_path, "r") as zf: |
|
shapes = parse_style(zf) |
|
with zf.open("mapdata.xml") as f: |
|
content = f.read().decode("utf-8", errors="replace") |
|
root = ET.fromstring(content) |
|
topic = extract_root_topic(root) |
|
if topic is None: |
|
return "mindmap\n (empty map)\n" |
|
topic_to_mermaid(topic, shapes, 0, " ", lines) |
|
return "\n".join(lines) + "\n" |
|
|
|
|
|
def main() -> None: |
|
parser = argparse.ArgumentParser(description="Convert iThoughts .itmz to Mermaid mindmap") |
|
parser.add_argument("itmz", help="Path to .itmz file") |
|
parser.add_argument("-o", "--output", help="Output file (default: stdout)") |
|
args = parser.parse_args() |
|
try: |
|
result = convert(args.itmz) |
|
if args.output: |
|
Path(args.output).write_text(result, encoding="utf-8") |
|
else: |
|
print(result, end="") |
|
except zipfile.BadZipFile as e: |
|
print(f"Error: Not a valid zip/itmz file: {e}", file=sys.stderr) |
|
sys.exit(1) |
|
except ET.ParseError as e: |
|
print(f"Error: Invalid XML in mapdata.xml: {e}", file=sys.stderr) |
|
sys.exit(1) |
|
except FileNotFoundError: |
|
print(f"Error: File not found: {args.itmz}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |