nnako / freeplane-python-io

a Python library to directly access Freeplane mindmap files
GNU General Public License v3.0
12 stars 5 forks source link

Comparing two mindmap (and node) objects #3

Open drakes00 opened 1 year ago

drakes00 commented 1 year ago

Hi,

I'm working on methods allowing to compare mindmaps by recursively comparing their nodes (eq). For the moment, I'm only comparing their plaintext attributes. Can you help me listing which node (and MM) attributes would need comparison so I can PR?

Thanks a lot

nnako commented 1 year ago

Hi @drakes00,

first, thank you for your interest in using and possibly enhancing this project.

Unfortunately, I am not sure to have understood what you want to do and where the problem lies. Please explain a bit further what you want to do and where the limitation / challenge of the library lies.

As you might already know, comparison of mindmap contents is not too easy to do. Especially, when nodes are so rich as with Freeplane. You ask for "needed" attributes when comparing mindmaps and their nodes, Here are a few:

So, first, you would have to define for yourself what you mean by "equality". If equality of node's plaintext values is sufficient for you, it's fine and in no way complicated. But as soon as e.g. you are working together with other people on a mindmap, either a strict ruleset is to be defined or all the possible changes have to be considered. And, yes, freeplane-python-io would be a good tool to help with this.

Within the Freeplane community, the subject of "comparison of mindmaps" was started within one of the discussions. It was tried to use applications like Notepad++ to do a XML text comparison which results in high complexity, as it is not easily possible to separate important from irrelevant information just by looking at the XML source of the mindmap.

One solution for comparing mindmaps could be to "convert" each mindmap into a "minimal" mindmap (only those node attributes are contained that are relevant for a comparison, so e.g. "folding state" would not be considered), and then do a textual comparison using e.g. Notepad++ or other tools which result in a line-based and colored comparison output. Where e.g. GREEN lines are new on either side, RED lines have been deleted from the other side, and YELLOW lines contain changes on both sides. This could be done using this library in combination with a suitable Python GUI library.

Anyway. Again, thanks for your interest.

Looking forward to reading from you again.

drakes00 commented 1 year ago

Hi @nnako,

Thanks a lot for this detailed answer. Please excuse the shortness of my initial question, I didn't thought it was a complicated issue :smiley:

To clarify my initial goal, I am relying on freeplane-python-io to parse mindmaps because I'm not ultra satisfied with the Markdown export feature of freeplane (with the increasing # characters). I am thus writing a piece of python code translating mindmaps into markdown the way I want it and I am writing testcases for this code. While wanting to test my code, I needed to compare what I am generating against what I consider as a good conversion in my case, leading to comparing mindmap objects.

I only considered the plaintext field to have a very simple working Mindmap.__eq__ method but I agree with you that only considering plaintext is way to loose of an equality property leading for my initial question on what should equality look like. This is what I have for the moment:

def node__eq__(self, __value: freeplane.Node) -> bool:
    if self.plaintext != __value.plaintext:                 
        return False                                                                  

    if len(self.children) != len(__value.children):
        return False

    return all([self.children[i] == __value.children[i] for i in range(len(self.children))])

def mm__eq__(self, __value: freeplane.Mindmap) -> bool:
    if self.rootnode != __value.rootnode:                                             
        return False                                                                  

    return True

The test on Node.plaintext in node__eq__ cas easily be complexified to take over attributes into account, which lead to my initial question. In my opinion, equality should consider all attributes that do not change on a save without modification, thus excluding attributes such as creation_date or modification_date. The fold_status is a good question, though. I feel like it doesn't change the map itself but rather its appearance.

Given the list you proposed earlier, I guess comparison could be done on:

Do you have preferences?

Regards,

nnako commented 1 year ago

Hi @drakes00 . Great idea. That is exactly why I initially intended when creating freeplane-python-io: to be able to interface out directly from the freeplane file. Without being dependent on the features of the Freeplane editor itself.

About comparing mindmaps, as I have understood your goal so far, you might be interested in the following:

I am still not sure that I have fully understood where exactly the use of freeplane-python-io supports your development. Is it just a part of the "testing"? In my opinion, I could make sense to use freeplane-python-io to create the whole exporting application. Like I would imagine other applications like

In every case, freeplane-python-io would be the interfacing layer between the two applications, directly. Prerequisit would always be that the structure of the source Freeplane mindmap is very strict or at least as flexible as you would expect people to intuitively work with it. Otherwise the conversion breaks when confronted with special cases.

But, even if I don't unserstand it fully, just go for it and we will see.

Thanks for your interest.

drakes00 commented 1 year ago
  • you might rather consider comparing trees not just single nodes. as I see it, currently, you only consider the 1st level children of a node.

It is actually the case, the line return all([self.children[i] == __value.children[i] for i in range(len(self.children))]) is comparing children which nodes thus recursively calling node__eq__ for each child (which will also recurse on its children as well.

I had this function overriding the __eq__ method with: freeplane.Node.__eq__ = node__eq__ (which would not be required if included natively in freeplane-python-io.

  • yes, your list of node properties seem to be reasonable for comparison. attributes and style only make sense for inclusion into equality comparison when they are based on a fix set of possible values.

OK cool, will PR soon with those and we can work from there.

  • to render your feature as flexible as possible (others might use it to export a slightly different markdown file), you would have to provide some parameters with your functions to setup provided operations.

This is actually not easy since the __eq__ method is standardize and does not take parmeters to the best of my knowledge? However, we could imagine providing an __eq__ method that does a simple comparison and a Mindmap.deepComparison() method that users can explicitly call for advanced comparison features?

I am still not sure that I have fully understood where exactly the use of freeplane-python-io supports your development. Is it just a part of the "testing"? In my opinion, I could make sense to use freeplane-python-io to create the whole exporting application. Like I would imagine other applications like

  • Freeplane -> Todolist
  • Freeplane -> Excel
  • Freeplane -> PowerPoint
  • Freeplane -> Confluence
  • ...

I am actually relying on feeplane-python-io to develop this export to markdown feature, and also to test this feature. We could actually consider including such conversions to various formats (mine is actually for Todolists). But I can probably work on it for a while and coming back to you with this export to Todolist feature if you would like it merged?

In every case, freeplane-python-io would be the interfacing layer between the two applications, directly. Prerequisit would always be that the structure of the source Freeplane mindmap is very strict or at least as flexible as you would expect people to intuitively work with it. Otherwise the conversion breaks when confronted with special cases.

Yes indeed.

Thanks for you opinion anyway, Regards,

EdoFro commented 1 year ago

Hi all, I use a few scripts to compare modifications/differences in mindmaps I use to modify them every time I use them, depending what I want to compare to define if two nodes are equql or not.

The first one is maybe easier to understand, but I don't use it very much

A. Comparation in one MindMap

If both branches are in the same map (sometimes I copy just the two branches I want to compare in a new map), I use this script:

tests = [
    {a,b -> a.text == b.text}
    //,{a,b -> a.id == b.id}
    ,{a,b -> a.details?.htmlText == b.details?.htmlText}
    ,{a,b -> a.note?.html == b.note?.html}
    ,{a,b -> a.children.size() == b.children.size()}
    ,{a,b -> a.children*.text == b.children*.text}
    ,{a,b -> a.attributes == a.attributes}
    ,{a,b -> a.style.name == a.style.name}
    ,{a,b -> a.icons.icons == a.icons.icons}
]

def (a,b) = c.selecteds.take(2)

println a.text
println b.text

println equals(a,b)

comparar(a,b)

def comparar(x,y){
    println x.text
    if(equals(x,y)){
        def xChilds = x.children
        def yChilds = y.children
        for(def i=0 ; i< xChilds.size(); i++){
            def resp = comparar(xChilds[i],yChilds[i])
            if(!resp){
                return false
            }
        }
        return true
    }else{
        equals(x,y, true)
        x.pathToRoot.dropRight(1).reverse()*.folded = false
        y.pathToRoot.dropRight(1).reverse()*.folded = false
        c.select([x,y])
        return false
    }
}

def equals(x,y,doPrint = false){
    def test = true
    def i = 0
    def iMax = tests.size()
    while(test && i<iMax){
        test = tests[i](x,y)
        if(doPrint){
            println i + '   ' + tests[i].toString() + '   ' + test
        }
        i++
    }
    println i
    return test
}

B. Comparing two versions of the same mindmap

It compares all the nodes, one by one. Both maps must be open and their names must start with the same string

def mapNameIni = 'Scripts' //here you must change the text with the initial text of the mindmaps names
def mapsFilter = {mapa -> mapa.name.startsWith(mapNameIni)}

//return uimsg('hola')

//list of tests to be performed by the comparator routine.
// You can add more or comment the ones you don't need (in my case it depends on the maps)
tests = [
         [ ' .id                          ' ,{a,b -> a.id == b.id}                                               ],
         [ ' .text                        ' ,{a,b -> a.text == b.text}                                           ],
         [ ' .details?.htmlText           ' ,{a,b -> a.details?.htmlText == b.details?.htmlText}                 ],
         [ ' .note?.plain                  ' ,{a,b -> a.note?.plain == b.note?.plain}                            ],
      //   [ ' .note?.html                  ' ,{a,b -> a.note?.html == b.note?.html}                               ],
         [ ' .children.size()             ' ,{a,b -> a.children.size() == b.children.size()}                     ],
         [ ' .children*.text              ' ,{a,b -> a.children*.text == b.children*.text}                       ],
         [ ' .attributes.size()           ' ,{a,b -> a.attributes.size() == b.attributes.size()}                 ],
         [ ' .attributes.names.sort()     ' ,{a,b -> a.attributes.names.sort() == b.attributes.names.sort()}     ],
         [ ' .attributes.getAll(nom)      ' ,{a,b -> (a.attributes.names + b.attributes.names).unique().sort().every
                                                            { a.attributes.getAll(it)*.toString().sort() == b.attributes.getAll(it)*.toString().sort() }
                                                       }],
         [ ' .style.name                  ' ,{a,b -> a.style.name == b.style.name}                               ],
         [ ' .link.text                   ' ,{a,b -> a.link.text == b.link.text}                                 ],
         [ ' .icons.icons.sort()          ' ,{a,b -> ([] + a.icons.icons).sort() ==([] + b.icons.icons).sort()}  ], //icons (no specific order)
         [ ' .icons.icons                 ' ,{a,b -> a.icons.icons == b.icons.icons}                             ], //icons in specific order
         [ ' .parent.getChildPosition(a)  ' ,{a,b -> a.isRoot() || (a.parent.getChildPosition(a) == b.parent.getChildPosition(b)) }  ]
]

isDifferentAttr = 'isDifferent'

//def (a,b) = c.selecteds
(a,b) = getRootsFromOpenMapsToCompare(mapsFilter)
//return "$a     $b"

a.find{n -> n.attributes.containsKey(isDifferentAttr)}.each{n-> n[isDifferentAttr] = null}
b.find{n -> n.attributes.containsKey(isDifferentAttr)}.each{n-> n[isDifferentAttr] = null}

//println a.text
//println b.text
//println equals(a,b)

def mapsAreEqual = comparar(a,b)
ui.informationMessage("${!mapsAreEqual?'No':mapsAreEqual} different nodes encountered in compared maps")

def comparar(x,y){
    def qDiff = 0
    if(!equals(x,y)){
        qDiff++
        print "${x.text} --> "
        def resp = equals(x,y, true)
        x[isDifferentAttr] = resp
        x.attributes.optimizeWidths()
        if(y){
            y[isDifferentAttr] = resp
            y.attributes.optimizeWidths()
        }
        def msg = "difference encountered in node:\n'${x.pathToRoot*.text.join('\' | \'')}'\n\ndifference: $resp"
        if (uimsg(msg) != 0){
            selectDifferentNodes(x,y)
            assert false
        }
    }

    //se debe comentar la opción 1 o 2 dependiendo lógica que se desee
    //TODO: meter en la lógica si se desea comparar id contra id o hijo contra hijo
    //TODO: o si se comparan ids vs ids de un lado y del otro:
    // intersección: comparar nodos en ambos mapas
    // resta: marcar como únicos los de cada mapa
    def xChilds = x.children
    //def yChilds =  //opción 1: compara hijo a hijo
    for(def i=0 ; i< xChilds.size(); i++){
        def xN = xChilds[i]
        def yN = y?.children?[i]   //opción 1: compara hijo a hijo
        //def yN = b.mindMap.root.find{it.id == xN.id}[0]  //opción 2: compara hijo contra igual id

        qDiff += comparar(xN,yN)
    }
    return qDiff
}

def equals(x,y,doLogPrint = false){
    def test = y?true:false
    def i = 0
    def iMax = tests.size()
    while(test && i<iMax){
        test = tests[i][1](x,y)
        i++
    }
    def msg = i?tests[i-1][0].toString():'it doesn\'t exist in the other map'
    if(doLogPrint && !test){
        println i + '.- ' + msg + '   ' + test
    }
    //println i==iMax
    return !doLogPrint ? test : msg
}

// returns the root nodes of the two maps to be compared
//( from the first two maps that fullfil the closure filter
def getRootsFromOpenMapsToCompare(Closure filter){
    def (map1,map2) = c.openMindMaps.findAll(filter).take(2)
    //present to user which are the selected maps
    def texto1 = """Maps to be compared
  map 1 :
    '${map1?.name}'
    ${map1?.file?.path}

  map 2 :
    '${map2?.name}'
    ${map2?.file?.path}

    """
    uimsgx(texto1)
    return [map1.root, map2.root]
}

def uimsgx(msg){
    assert 0 == uimsg(msg)
}
def uimsg(msg){
    ui.showConfirmDialog(null, msg.toString(),'Continue?',2)
}

def selectDifferentNodes(x,y){
        x ?= a.mindMap.root
        c.mapLoader(x.mindMap.file).withView().getMindMap()
        sleep(500)
        x.pathToRoot.dropRight(1).reverse()*.folded = false
        c.centerOnNode(x)
        c.select(x)
        sleep(500)

        y ?= b.mindMap.root
        c.mapLoader(y.mindMap.file).withView().getMindMap()
        sleep(500)
        y.pathToRoot.dropRight(1).reverse()*.folded = false
        c.centerOnNode(y)
        c.select(y)
        sleep(500)
}

Help script: select different nodes

def nodos = c.find{n->
    n['isDifferent']?true:false
}

seleccionar(nodos)

def seleccionar(nds){
    nds.each{n ->
        n.pathToRoot*.folded = false
    }
    c.select(nds)
    c.centerOnNode(nds.first())
}

Help script: delete ['isDifferent'] attribute from active map

def isDifferentAttr = 'isDifferent'
node.mindMap.root.find{n -> n.attributes.containsKey(isDifferentAttr)}.each{n-> n[isDifferentAttr] = null}

Hope this helps,

edo

EdoFro commented 1 year ago

Sorry, I just realized this is Freeplane-python-io Discussion. I thought I was answering in the Freeplane's forum.

nnako commented 1 year ago

Hi @EdoFro . No problem ;-) . Maybe @drakes00 will drag some valuable lines from your Java / Groovy script.

You are always very welcome to contribute.