Bill Farmer

Random thoughts on random subjects

Parsing markdown to create a mindmap

by Bill Farmer. Categories: hacking .

I was requested to investigate importing a markdown mindmap into my MindMap app. The app uses the MindMapView library for the display. There is a commonmark library that I have used in other apps to handle markdown, so I investigated if it is possible to use it for this.

The library provides an AbstractVisitor which you may use to visit the tree of nodes generated by the parser. Because at least one implementation of MarkMap uses YAML front matter to define the title/root node there is a YamlFrontMatterExtension and a YamlFrontMatterVisitor to deal with that.

So you start off with something like this:

---
title: Test
---
## Node 1
- Node 1.1
- Node 1.2
## Node 2
- Node [Link](https://example.org) 2.1
  - Node 2.1.1
    - Node 2.1.1.1
### Node 2.2
#### Node **Bold** 2.2.1
##### Node 2.2.1.1
###### Node 2.2.1.1.1

And you get this:

MindMap

First, read the markdown and give it to the commonmark parser, including the YamlFrontMatterExtension.

        // Get the text
        StringBuilder text = new StringBuilder();
        try (BufferedReader reader = new BufferedReader(new InputStreamReader
               (getContentResolver().openInputStream(uri))))
        {
            String line;
            while ((line = reader.readLine()) != null)
            {
                text.append(line);
                text.append(System.getProperty("line.separator"));
            }

            // Use commonmark
            List<Extension> extensions =
                List.of(YamlFrontMatterExtension.create());
            Parser parser = Parser.builder().extensions(extensions).build();
            Node document = parser.parse(text.toString());

The document contains a tree of commonmark nodes which can be navigated using the API. Create a new MindMapView tree and initialise the mindmap view.

            // New tree
            tree = new Tree<>(this);
            mindMapView.setTree(tree);
            mindMapView.initialize();

Create a list to keep track of nodes and handle YAML front matter if it exists. The root node must be replaced to change the description.

            List<String> nodeList = new ArrayList<>();
            document.accept(new YamlFrontMatterVisitor()
            {
                @Override
                public void visit(CustomNode custom)
                {
                    int level = 0;
                    YamlFrontMatterNode node = (YamlFrontMatterNode)custom;
                    if (TITLE.equals(node.getKey()))
                    {
                        NodeData<?> root = tree.getRootNode();
                        tree.updateNode(root.getId(),
                                        node.getValues().get(0),
                                        root.getChildren(),
                                        root.getPath().getCenterX(),
                                        root.getPath().getCenterY());
                        nodeList.add(level, root.getId());
                        name = node.getValues().get(0);
                        setTitle(name);
                    }
                }
            });

Handle header nodes, both H1 headers and others. Get the text first, this makes assumptions about the structure of the commonmark node tree.

            document.accept(new AbstractVisitor()
            {
                int level = 0;
                // Heading
                @Override
                public void visit(Heading heading)
                {
                    // Get content
                    StringBuilder content = new StringBuilder();
                    Node child = heading.getFirstChild();
                    while (child != null)
                    {
                        if (child instanceof Text)
                            content.append(((Text)child).getLiteral());

                        else if (child instanceof Emphasis ||
                                 child instanceof StrongEmphasis ||
                                 child instanceof Link)
                            content.append(((Text)child.getFirstChild())
                                           .getLiteral());

                        child = child.getNext();
                    }

Handle a H1 header, similar to the above. The level variable is used to keep track of header level and bullet/ordered list levels. Set the name and the app title as above. The name is used when saving the map.

                    // Title
                    if (heading.getLevel() == 1)
                    {
                        level = heading.getLevel() - 1;
                        NodeData<?> root = tree.getRootNode();
                        tree.updateNode(root.getId(),
                                        content.toString(),
                                        root.getChildren(),
                                        root.getPath().getCenterX(),
                                        root.getPath().getCenterY());
                        nodeList.add(level, root.getId());
                        name = content.toString();
                        setTitle(name);
                    }

Handle other header nodes. The call to super handles recursion. To add a node you need an id, a parentId and the content.

                    else
                    {
                        level = heading.getLevel() - 1;
                        String id = UUID.randomUUID().toString();
                        tree.addNode(id,
                                     nodeList.get(level - 1),
                                     content.toString());
                        nodeList.add(level, id);
                    }

                    super.visit(heading);
                }

Handle a bullet list node. Check if the parent is a list item, if so increase the level for subsequent list items. Ordered list nodes are handled identically.

                // BulletList
                @Override
                public void visit(BulletList list)
                {
                    if (list.getParent() instanceof ListItem)
                        level++;

                    super.visit(list);
                }

Handle a list item. We don’t care what sort of list. Get the text first, the parser adds a Paragraph node after list items, so a bit of recursion.

                // ListItem
                @Override
                public void visit(ListItem item)
                {
                    // Get content
                    StringBuilder content = new StringBuilder();
                    Node child = item.getFirstChild();
                    while (child != null)
                    {
                        if (child instanceof Text)
                            content.append(((Text)child).getLiteral());

                        else if (child instanceof Emphasis ||
                                 child instanceof StrongEmphasis ||
                                 child instanceof Link)
                            content.append(((Text)child.getFirstChild())
                                           .getLiteral());

                        if (child instanceof Paragraph)
                            child = child.getFirstChild();

                        else
                            child = child.getNext();
                    }

Create a node. This code ignores all other possible content, like code blocks, inline code, etc.

                    // Node
                    String id = UUID.randomUUID().toString();
                    tree.addNode(id,
                                 nodeList.get(level - 1),
                                 content.toString());
                    nodeList.add(level, id);

                    super.visit(item);
                }
            });

Although there is no such thing as a markdown syntax error, irrational markdown will break this code. It just generates an Exception and gives up.


See Also