mindmap.serialization module

mindmap.serialization.build_mapping(topic, guid_mapping)

Build a mapping of GUIDs to numeric IDs for an entire mindmap.

Parameters:
  • topic (MindmapTopic) – Root topic of the mindmap

  • guid_mapping (dict) – Dictionary to store GUID to ID mappings

Returns:

The mapping is updated in-place

Return type:

None

mindmap.serialization.deserialize_mermaid_full(mermaid_text: str, guid_mapping: dict) MindmapTopic

Convert Mermaid text with metadata to a Mindmap structure.

Parameters:
  • mermaid_text (str) – Mermaid formatted string with JSON metadata to parse

  • guid_mapping (dict) – Dictionary mapping numeric IDs to GUIDs

Returns:

Root topic of the fully deserialized mindmap with all attributes

Return type:

MindmapTopic

mindmap.serialization.deserialize_mermaid_simple(mermaid_text: str) MindmapTopic

Convert a simple indentation-based Mermaid mindmap to a Mindmap structure.

Parameters:

mermaid_text (str) – Mermaid formatted string containing only textual nodes expressed via indentation (no IDs or metadata)

Returns:

Root topic of the deserialized mindmap

Return type:

MindmapTopic

mindmap.serialization.deserialize_mermaid_with_id(mermaid_text: str, guid_mapping: dict) MindmapTopic

Convert Mermaid text with id to a Mindmap structure.

Parameters:
  • mermaid_text (str) – Mermaid formatted string to parse

  • guid_mapping (dict) – Dictionary mapping numeric IDs to GUIDs

Returns:

Root topic of the deserialized mindmap

Return type:

MindmapTopic

mindmap.serialization.serialize_mindmap(root_topic, guid_mapping, id_only=False)

Serialize a mindmap to valid Mermaid format including id and all other attributes (optional).

Parameters:
  • root_topic (MindmapTopic) – Root topic of the mindmap

  • guid_mapping (dict) – Dictionary mapping GUIDs to numeric IDs

  • id_only (bool, optional) – If True, only include IDs without detailed attributes. Defaults to False.

Returns:

Mermaid formatted string representing the mindmap

Return type:

str

mindmap.serialization.serialize_mindmap_markdown(root_topic, include_notes=True)

Serialize a mindmap to markdown including notes (optional).

Parameters:
  • root_topic (MindmapTopic) – Root topic of the mindmap

  • include_notes (bool, optional) – If True, notes are included

Returns:

Markdown formatted string representing the mindmap

Return type:

str

mindmap.serialization.serialize_mindmap_simple(root_topic: MindmapTopic) str

Serialize a mindmap to a simplified Mermaid format with indentation-only nodes.

Parameters:

root_topic (MindmapTopic) – Root topic of the mindmap

Returns:

Mermaid formatted string (text and indentation only)

Return type:

str

mindmap.serialization.serialize_object(obj, guid_mapping, name='', visited=None, ignore_rtf=True)

Serialize an object recursively, handling special fields and mapping GUIDs to IDs.

Parameters:
  • obj – The object to serialize

  • guid_mapping – Dictionary mapping GUIDs to numeric IDs

  • name (str, optional) – The name of the attribute being serialized. Defaults to ‘’.

  • visited (set, optional) – Set of object IDs that have been visited to prevent cycles. Defaults to None.

  • ignore_rtf (bool, optional) – Whether to ignore RTF content. Defaults to True.

Returns:

Serialized representation of the input object to be exported at JSON

Return type:

object

mindmap.serialization.serialize_object_simple(obj, name='', visited=None, ignore_rtf=True)

Serialize an object recursively without GUID mapping.

Parameters:
  • obj – The object to serialize

  • name (str, optional) – The name of the attribute being serialized. Defaults to ‘’.

  • visited (set, optional) – Set of object IDs that have been visited to prevent cycles. Defaults to None.

  • ignore_rtf (bool, optional) – Whether to ignore RTF content. Defaults to True.

Returns:

Simplified serialized representation of the input object to be exported at JSON

Return type:

object

Source code for serialization.py
  1from mindmap.mindmap import *
  2from mindmap import helpers
  3
  4import json
  5import re
  6import uuid
  7import html2text
  8
  9IGNORE_RTF = True
 10
 11def serialize_object(obj, guid_mapping, name='', visited=None, ignore_rtf=True):
 12    """Serialize an object recursively, handling special fields and mapping GUIDs to IDs.
 13    
 14    Args:
 15        obj: The object to serialize
 16        guid_mapping: Dictionary mapping GUIDs to numeric IDs
 17        name (str, optional): The name of the attribute being serialized. Defaults to ''.
 18        visited (set, optional): Set of object IDs that have been visited to prevent cycles. Defaults to None.
 19        ignore_rtf (bool, optional): Whether to ignore RTF content. Defaults to True.
 20        
 21    Returns:
 22        object: Serialized representation of the input object to be exported at JSON
 23    """
 24    if visited is None:
 25        visited = set()
 26    if name == 'topic':
 27        if id(obj) in visited:
 28            return None
 29        visited.add(id(obj))
 30    visited.add(id(obj))
 31    if isinstance(obj, (str, int, float, bool, type(None))):
 32        return obj
 33    if isinstance(obj, list):
 34        attr_name = 'topic' if name == 'subtopics' else ''
 35        return [serialize_object(item, guid_mapping, attr_name, visited) for item in obj]
 36    if isinstance(obj, dict):
 37        return {str(k): serialize_object(v, guid_mapping, visited=visited) for k, v in obj.items()}
 38    if hasattr(obj, '__dict__'):
 39        serialized = {}
 40        for attr_name, attr_value in vars(obj).items():
 41            if attr_name in ["parent", "level", "selected"]: 
 42                continue
 43            if attr_name in ["rtf"]:
 44                if ignore_rtf == True:
 45                    continue
 46            if attr_value is None or attr_value == "" or attr_value == []:
 47                continue
 48            new_attr_name = attr_name
 49            if new_attr_name in ["guid", "guid_1", "guid_2"]:
 50                if new_attr_name == "guid":
 51                    new_attr_name = "id"
 52                elif new_attr_name == "guid_1":
 53                    new_attr_name = "id_1"
 54                elif new_attr_name == "guid_2":
 55                    new_attr_name = "id_2"
 56                serialized[new_attr_name] = guid_mapping[attr_value]
 57            else:
 58                dict_val = serialize_object(attr_value, guid_mapping, attr_name, visited)
 59                if dict_val != {}:
 60                    serialized[new_attr_name] = dict_val
 61        return serialized
 62    return str(obj)
 63
 64def serialize_object_simple(obj, name='', visited=None, ignore_rtf=True):
 65    """Serialize an object recursively without GUID mapping.
 66    
 67    Args:
 68        obj: The object to serialize
 69        name (str, optional): The name of the attribute being serialized. Defaults to ''.
 70        visited (set, optional): Set of object IDs that have been visited to prevent cycles. Defaults to None.
 71        ignore_rtf (bool, optional): Whether to ignore RTF content. Defaults to True.
 72        
 73    Returns:
 74        object: Simplified serialized representation of the input object to be exported at JSON
 75    """
 76    if visited is None:
 77        visited = set()
 78    if name == 'topic':
 79        if id(obj) in visited:
 80            return None
 81        visited.add(id(obj))
 82    if isinstance(obj, (str, int, float, bool, type(None))):
 83        return obj
 84    if isinstance(obj, list):
 85        attr_name = 'topic' if name == 'subtopics' else ''
 86        return [serialize_object_simple(item, attr_name, visited) for item in obj]
 87    if isinstance(obj, dict):
 88        return {str(k): serialize_object_simple(v, k, visited) for k, v in obj.items()}
 89    if hasattr(obj, '__dict__'):
 90        serialized = {}
 91        for attr_name, attr_value in vars(obj).items():
 92            if attr_name in ["parent", "level", "selected"]:
 93                continue
 94            if attr_name in ["rtf"]:
 95                if ignore_rtf == True:
 96                    continue
 97            if attr_value is None or attr_value == "" or attr_value == []:
 98                continue
 99            dict_val = serialize_object_simple(attr_value, attr_name, visited)
100            if dict_val != {}:
101                serialized[attr_name] = dict_val
102        return serialized
103    return str(obj)
104
105def serialize_mindmap(root_topic, guid_mapping, id_only=False):
106    """Serialize a mindmap to valid Mermaid format including id and all other attributes (optional).
107    
108    Args:
109        root_topic (MindmapTopic): Root topic of the mindmap
110        guid_mapping (dict): Dictionary mapping GUIDs to numeric IDs
111        id_only (bool, optional): If True, only include IDs without detailed attributes. Defaults to False.
112        
113    Returns:
114        str: Mermaid formatted string representing the mindmap
115    """
116    lines = ["mindmap"]
117
118    def serialize_topic_attributes(topic, guid_mapping, ignore_rtf=True):
119        """Extract and serialize the attributes of a MindmapTopic.
120        
121        Args:
122            topic (MindmapTopic): The topic to serialize
123            guid_mapping (dict): Dictionary mapping GUIDs to numeric IDs
124            
125        Returns:
126            dict: Dictionary containing serialized topic attributes
127        """
128        d = {}
129        d["id"] = guid_mapping.get(topic.guid, topic.guid)
130        #d["text"] = topic.text
131        if topic.rtf != topic.text and not ignore_rtf == True:
132            d["rtf"] = topic.rtf
133        if topic.selected == True:
134            d["selected"] = topic.selected
135        if topic.links:
136            d["links"] = []
137            for link in topic.links:
138                l = {}
139                if link.text:
140                    l["text"] = link.text
141                if link.url:
142                    l["url"] = link.url
143                if link.guid:
144                    l["id"] = guid_mapping.get(link.guid, link.guid)
145                d["links"].append(l)
146        if topic.image:
147            d["image"] = {"text": topic.image.text}
148        if topic.icons:
149            d["icons"] = []
150            for icon in topic.icons:
151                i = {}
152                if icon.text:
153                    i["text"] = icon.text
154                if icon.is_stock_icon is not None:
155                    i["is_stock_icon"] = icon.is_stock_icon
156                if icon.index is not None:
157                    i["index"] = icon.index
158                if icon.signature:
159                    i["signature"] = icon.signature
160                if icon.path:
161                    i["path"] = icon.path
162                if icon.group:
163                    i["group"] = icon.group
164                d["icons"].append(i)
165        if topic.notes and (topic.notes.text or topic.notes.xhtml or topic.notes.rtf):
166            notes = {}
167            if topic.notes.text:
168                notes["text"] = topic.notes.text
169            if topic.notes.xhtml:
170                notes["xhtml"] = topic.notes.xhtml
171            if topic.notes.rtf:
172                notes["rtf"] = topic.notes.rtf
173            if notes != {}:
174                d["notes"] = notes
175        if topic.tags:
176            d["tags"] = [tag.text for tag in topic.tags]
177        if topic.references:
178            d["references"] = []
179            for ref in topic.references:
180                r = {}
181                if ref.guid_1:
182                    r["id_1"] = guid_mapping.get(ref.guid_1, ref.guid_1)
183                if ref.guid_2:
184                    r["id_2"] = guid_mapping.get(ref.guid_2, ref.guid_2)
185                if ref.direction:
186                    r["direction"] = ref.direction
187                if ref.label:
188                    r["label"] = ref.label
189                d["references"].append(r)
190        d = helpers.replace_unicode_in_obj(d)
191        return d
192    
193    def traverse(topic, indent):
194        indent_str = "  " * indent
195        node_text = helpers.escape_mermaid_text(topic.text)
196        if id_only:
197            id = guid_mapping.get(topic.guid, topic.guid)
198            line = f"{indent_str}id{id}[{node_text}]"
199            #line = f"{indent_str}({node_text})"
200            #topic_attrs = {"id": id}
201        else:
202            line = f"{indent_str}[{node_text}]"
203            topic_attrs = serialize_topic_attributes(topic, guid_mapping, ignore_rtf=IGNORE_RTF)
204            json_comment = json.dumps(topic_attrs, ensure_ascii=True)
205            line += f" %% {json_comment}"
206        lines.append(line)
207        for sub in topic.subtopics:
208            traverse(sub, indent + 1)
209
210    traverse(root_topic, 1)
211    return "\n".join(lines)
212
213def serialize_mindmap_simple(root_topic: MindmapTopic) -> str:
214    """Serialize a mindmap to a simplified Mermaid format with indentation-only nodes.
215    
216    Args:
217        root_topic (MindmapTopic): Root topic of the mindmap
218        
219    Returns:
220        str: Mermaid formatted string (text and indentation only)
221    """
222    lines = ["mindmap"]
223
224    def traverse(topic, indent):
225        indent_str = "  " * indent
226        text = topic.text if topic.text is not None else ""
227        lines.append(f"{indent_str}{text}")
228        for sub in topic.subtopics:
229            traverse(sub, indent + 1)
230
231    traverse(root_topic, 1)
232    return "\n".join(lines)
233
234def serialize_mindmap_markdown(root_topic, include_notes=True):
235    """Serialize a mindmap to markdown including notes (optional).
236    
237    Args:
238        root_topic (MindmapTopic): Root topic of the mindmap
239        include_notes (bool, optional): If True, notes are included
240        
241    Returns:
242        str: Markdown formatted string representing the mindmap
243    """
244
245    lines = []
246    
247    def traverse(topic, lines, level, prefix, index):
248        text = topic.text
249        notes_text = ""
250        notes_xhtml = ""
251        notes_rtf = ""
252
253        if level > 0:
254            if prefix == '':
255                prefix = str(index)
256            else:
257                prefix = f"{prefix}.{index}"
258        
259        if topic.notes:
260            if topic.notes.text or topic.notes.xhtml or topic.notes.rtf:
261                if topic.notes.text:
262                    notes_text = topic.notes.text
263                if topic.notes.xhtml:
264                    xhtml = topic.notes.xhtml
265                    root_match = re.search(r'<(?:root|body)[^>]*>(.*?)</(?:root|body)>', xhtml, re.DOTALL | re.IGNORECASE)
266                    if root_match:
267                        xhtml = root_match.group(1)
268                    xhtml = re.sub(r'<\?xml[^>]*\?>', '', xhtml)
269                    xhtml = re.sub(r'<!DOCTYPE[^>]*>', '', xhtml)
270                    try:
271                        h = html2text.HTML2Text()
272                        h.ignore_links = False
273                        h.ignore_images = False
274                        h.body_width = 0  # Don't wrap lines
275                        notes_xhtml = h.handle(xhtml).strip()
276                    except ImportError:
277                        notes_xhtml = re.sub(r'<[^>]*>', '', xhtml).strip()
278                if topic.notes.rtf:
279                    # not implemented due to bad results
280                    pass
281
282        if include_notes and (notes_text or notes_xhtml or notes_rtf):
283            notes_content = notes_text
284            if notes_rtf:
285                notes_content = notes_rtf
286            if notes_xhtml:
287                notes_content = notes_xhtml
288            notes = f"Notes: {notes_content}  "
289        else:
290            notes = ""
291        
292        if topic.subtopics:
293            line = f"{(level + 1) * '#'} {prefix if level > 0 else ''} {text}  "
294            lines.append(line)
295            if notes:
296                lines.append(notes)
297
298            sub_index = 0
299            for sub in topic.subtopics:
300                sub_index += 1
301                traverse(sub, lines, level + 1, prefix, sub_index)
302        else:
303            line = f"- {text}  "
304            lines.append(line)
305            if notes:
306                lines.append(notes)
307
308    traverse(root_topic, lines, 0, '', 0)
309    return "\n".join(lines)
310
311
312def deserialize_mermaid_with_id(mermaid_text: str, guid_mapping: dict) -> MindmapTopic:
313    """Convert Mermaid text with id to a Mindmap structure.
314    
315    Args:
316        mermaid_text (str): Mermaid formatted string to parse
317        guid_mapping (dict): Dictionary mapping numeric IDs to GUIDs
318        
319    Returns:
320        MindmapTopic: Root topic of the deserialized mindmap
321    """
322    id_to_guid = {id_num: guid for guid, id_num in guid_mapping.items()}
323    lines = [line for line in mermaid_text.splitlines() if line.strip()]
324    if lines and lines[0].strip().lower() == "mindmap":
325        lines = lines[1:]
326    node_pattern = re.compile(r"^(id(\d+))\[(.*)\]$")
327    root = None
328    stack = []
329    for line in lines:
330        indent = len(line) - len(line.lstrip(" "))
331        level = indent // 2
332        stripped = line.lstrip(" ")
333        match = node_pattern.match(stripped)
334        if not match:
335            continue        
336        node_id_str = match.group(1)
337        id_number = int(match.group(2))
338        node_text = match.group(3)        
339        if id_number in id_to_guid:
340            guid = id_to_guid[id_number]
341        else:
342            guid = str(uuid.uuid4())
343            id_to_guid[id_number] = guid
344        node = MindmapTopic(guid=guid, text=node_text, level=level)
345        if root is None:
346            root = node
347            stack.append((level, node))
348            continue
349        while stack and stack[-1][0] >= level:
350            stack.pop()
351        if stack:
352            parent = stack[-1][1]
353            node.parent = parent
354            parent.subtopics.append(node)
355        else:
356            root.subtopics.append(node)
357            node.parent = root
358        stack.append((level, node))
359    return root
360
361def deserialize_mermaid_simple(mermaid_text: str) -> MindmapTopic:
362    """Convert a simple indentation-based Mermaid mindmap to a Mindmap structure.
363    
364    Args:
365        mermaid_text (str): Mermaid formatted string containing only textual nodes
366            expressed via indentation (no IDs or metadata)
367        
368    Returns:
369        MindmapTopic: Root topic of the deserialized mindmap
370    """
371    lines = [line for line in mermaid_text.splitlines() if line.strip()]
372    if lines and lines[0].strip().lower() == "mindmap":
373        lines = lines[1:]
374    root = None
375    stack = []
376    for raw_line in lines:
377        # Allow optional inline comments and convert tabs to spaces for consistent indentation
378        line = raw_line.expandtabs(2)
379        line = line.split("%%", 1)[0]
380        stripped = line.strip()
381        if not stripped:
382            continue
383        if stripped.startswith("[") and stripped.endswith("]"):
384            stripped = stripped[1:-1]
385        indent = len(line) - len(line.lstrip(" "))
386        level = indent // 2
387        node = MindmapTopic(guid=str(uuid.uuid4()), text=stripped, level=level)
388        if root is None:
389            root = node
390            stack = [(level, node)]
391            continue
392        while stack and stack[-1][0] >= level:
393            stack.pop()
394        if stack:
395            parent = stack[-1][1]
396            node.parent = parent
397            parent.subtopics.append(node)
398        else:
399            # Fallback for malformed inputs that contain multiple root-level nodes
400            if root:
401                node.parent = root
402                root.subtopics.append(node)
403        stack.append((level, node))
404    return root
405
406def deserialize_mermaid_full(mermaid_text: str, guid_mapping: dict) -> MindmapTopic:
407    """Convert Mermaid text with metadata to a Mindmap structure.
408    
409    Args:
410        mermaid_text (str): Mermaid formatted string with JSON metadata to parse
411        guid_mapping (dict): Dictionary mapping numeric IDs to GUIDs
412        
413    Returns:
414        MindmapTopic: Root topic of the fully deserialized mindmap with all attributes
415    """
416    id_to_guid = {v: k for k, v in guid_mapping.items()}
417    lines = [line for line in mermaid_text.splitlines() if line.strip()]
418    if lines and lines[0].strip().lower() == "mindmap":
419        lines = lines[1:]
420    pattern = re.compile(r"^( *)(\[.*?\])\s*%%\s*(\{.*\})\s*$")
421    root = None
422    stack = []
423    
424    def restore_guid(numeric_id):
425        try:
426            num = int(numeric_id)
427        except:
428            return str(uuid.uuid4())
429        if num in id_to_guid:
430            return id_to_guid[num]
431        else:
432            new_guid = str(uuid.uuid4())
433            id_to_guid[num] = new_guid
434            return new_guid
435
436    def process_subobject(field_dict: dict, id_field: str) -> dict:
437        if id_field in field_dict:
438            field_dict[id_field] = restore_guid(field_dict[id_field])
439        return field_dict
440
441    for line in lines:
442        m = pattern.match(line)
443        if not m:
444            continue
445        indent_str, bracket_part, json_part = m.groups()
446        level = len(indent_str) // 2
447        fallback_text = bracket_part.strip()[1:-1]
448        try:
449            attrs = json.loads(json_part)
450        except json.JSONDecodeError as e:
451            if "Invalid \\escape" in e.msg:
452                repaired_json = re.sub(r'(?<!\\)\\(?![\\\"/bfnrtu])', r'\\\\', json_part)
453                try:
454                    attrs = json.loads(repaired_json)
455                except Exception as inner:
456                    print(f"Failed to parse Mermaid JSON metadata even after repairing backslashes: {inner} (original: {json_part!r})")
457                    attrs = {}
458            else:
459                print(f"Failed to parse Mermaid JSON metadata: {e.msg} at pos {e.pos} in {json_part!r}")
460                attrs = {}
461        except Exception as e:
462            print(f"Unexpected error while parsing Mermaid JSON metadata {json_part!r}: {e}")
463            attrs = {}
464        if "id" in attrs:
465            node_guid = restore_guid(attrs["id"])
466        else:
467            node_guid = str(uuid.uuid4())
468        node_text = attrs.get("text", fallback_text)
469        node_rtf = attrs.get("rtf", "")
470        selected = attrs.get("selected", False)
471        links = []
472        if "links" in attrs and isinstance(attrs["links"], list):
473            for link_dict in attrs["links"]:
474                ld = dict(link_dict)
475                ld = process_subobject(ld, "id")
476                link_text = ld.get("text", "")
477                link_url = ld.get("url", "")
478                link_guid = ld.get("id", "")
479                links.append(MindmapLink(text=link_text, url=link_url, guid=link_guid))
480        image_obj = None
481        if "image" in attrs and isinstance(attrs["image"], dict):
482            image_obj = MindmapImage(text=attrs["image"].get("text", ""))
483        icons = []
484        if "icons" in attrs and isinstance(attrs["icons"], list):
485            for icon_dict in attrs["icons"]:
486                idict = dict(icon_dict)
487                icons.append(MindmapIcon(
488                    text=idict.get("text", ""),
489                    is_stock_icon=idict.get("is_stock_icon", True),
490                    index=idict.get("index", 1),
491                    signature=idict.get("signature", ""),
492                    path=idict.get("path", ""),
493                    group=idict.get("group", "")
494                ))
495        notes_obj = None
496        if "notes" in attrs:
497            if isinstance(attrs["notes"], dict):
498                notes_obj = MindmapNotes(
499                    text=attrs["notes"].get("text", ""),
500                    xhtml=attrs["notes"].get("xhtml", ""),
501                    rtf=attrs["notes"].get("rtf", "")
502                )
503            elif isinstance(attrs["notes"], str):
504                notes_obj = MindmapNotes(text=attrs["notes"])
505        tags = []
506        if "tags" in attrs and isinstance(attrs["tags"], list):
507            for tag_item in attrs["tags"]:
508                if isinstance(tag_item, dict):
509                    tag_text = tag_item.get("text", "")
510                else:
511                    tag_text = str(tag_item)
512                tags.append(MindmapTag(text=tag_text))
513        references = []
514        if "references" in attrs and isinstance(attrs["references"], list):
515            for ref_dict in attrs["references"]:
516                rd = dict(ref_dict)
517                rd = process_subobject(rd, "id_1")
518                rd = process_subobject(rd, "id_2")
519                direction = rd.get("direction", None)
520                label = rd.get("label", "")
521                references.append(MindmapReference(
522                    guid_1=rd.get("id_1", ""),
523                    guid_2=rd.get("id_2", ""),
524                    direction=direction,
525                    label=label
526                ))
527        node = MindmapTopic(guid=node_guid, text=node_text, rtf=node_rtf, level=level, selected=selected)
528        node.links = links
529        node.image = image_obj
530        node.icons = icons
531        node.notes = notes_obj
532        node.tags = tags
533        node.references = references
534        while stack and stack[-1][0] >= level:
535            stack.pop()
536        if stack:
537            parent = stack[-1][1]
538            node.parent = parent
539            parent.subtopics.append(node)
540        else:
541            root = node
542        stack.append((level, node))
543    return root
544
545def build_mapping(topic, guid_mapping):
546    """Build a mapping of GUIDs to numeric IDs for an entire mindmap.
547    
548    Args:
549        topic (MindmapTopic): Root topic of the mindmap
550        guid_mapping (dict): Dictionary to store GUID to ID mappings
551        
552    Returns:
553        None: The mapping is updated in-place
554    """
555    if topic.guid not in guid_mapping:
556        guid_mapping[topic.guid] = len(guid_mapping) + 1
557    for sub in topic.subtopics:
558        build_mapping(sub, guid_mapping)