← Back to team overview

gtg team mailing list archive

[Merge] lp:~gtg-user/gtg/tomboy-backend into lp:gtg

 

Luca Invernizzi has proposed merging lp:~gtg-user/gtg/tomboy-backend into lp:gtg with lp:~gtg-user/gtg/multibackends-halfgsoc_merge as a prerequisite.

Requested reviews:
  Lionel Dricot (ploum)
  Gtg developers (gtg)


The tomboy backend. Any tomboy note matching a particular tag will be inserted (r/w) in GTG.
It handles gracefully the change of the "attached tags", that are the tags to which the backend is looking for. The r/w synchronization engine is included.

It's complemented with a series of testcases and exception handling (when tomboy is put under stress - ~500 notes on my laptop - it begins to drop connections on dbus).


This is just to show the code for a review. I'll need to review the docstrings and use this backend for a couple of weeks before considering it stable - it works pretty well so far-.

-- 
https://code.launchpad.net/~gtg-user/gtg/tomboy-backend/+merge/28257
Your team Gtg developers is requested to review the proposed merge of lp:~gtg-user/gtg/tomboy-backend into lp:gtg.
=== added file 'GTG/backends/backend_tomboy.py'
--- GTG/backends/backend_tomboy.py	1970-01-01 00:00:00 +0000
+++ GTG/backends/backend_tomboy.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,452 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Gettings Things Gnome! - a personal organizer for the GNOME desktop
+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program.  If not, see <http://www.gnu.org/licenses/>.
+# -----------------------------------------------------------------------------
+
+'''
+'''
+#To introspect tomboy: qdbus org.gnome.Tomboy /org/gnome/Tomboy/RemoteControl
+
+import os
+import re
+import threading
+import uuid
+import dbus
+import datetime
+
+from GTG.tools.borg              import Borg
+from GTG.backends.genericbackend import GenericBackend
+from GTG                         import _
+from GTG.backends.backendsignals import BackendSignals
+from GTG.backends.syncengine     import SyncEngine, SyncMeme
+from GTG.tools.logger            import Log
+from GTG.tools.watchdog          import Watchdog
+
+
+
+class Backend(GenericBackend):
+    
+
+    _general_description = { \
+        GenericBackend.BACKEND_NAME:       "backend_tomboy", \
+        GenericBackend.BACKEND_HUMAN_NAME: _("Tomboy/Gnote"), \
+        GenericBackend.BACKEND_AUTHORS:    ["Luca Invernizzi"], \
+        GenericBackend.BACKEND_TYPE:       GenericBackend.TYPE_READWRITE, \
+        GenericBackend.BACKEND_DESCRIPTION: \
+            _("This backend can synchronize all or part of your Tomboy"
+              "/Gnote notes in GTG. If you decide it would be handy to"
+              " have one of your notes in your TODO list, just tag it "
+              "with the tag you have chosen (you'll configure it later"
+              "), and it will appear in GTG."),\
+        }
+
+    _static_parameters = { \
+        GenericBackend.KEY_ATTACHED_TAGS: {\
+            GenericBackend.PARAM_TYPE: GenericBackend.TYPE_LIST_OF_STRINGS, \
+            GenericBackend.PARAM_DEFAULT_VALUE: ["@GTG-Tomboy"]}, \
+        }
+
+###############################################################################
+### Backend standard methods ##################################################
+###############################################################################
+
+    def __init__(self, parameters):
+        """
+        Instantiates a new backend.
+
+        @param parameters: should match the dictionary returned in
+        get_parameters. Anyway, the backend should care if one expected
+        value is None or does not exist in the dictionary. 
+        """
+        super(Backend, self).__init__(parameters)
+        #loading the saved state of the synchronization, if any
+        self._tomboy_setting_timers = {}
+        self.data_path = os.path.join('backends/tomboy/', \
+                                      "sync_engine-" + self.get_id())
+        self.sync_engine = self._load_pickled_file(self.data_path, \
+                                                   SyncEngine())
+        if self._parameters.has_key("use this fake connection instead"):
+            #just used for testing purposes
+            self.BUS_ADDRESS = \
+                    self._parameters["use this fake connection instead"]
+        else:
+            self.BUS_ADDRESS = ("org.gnome.Tomboy",
+                                "/org/gnome/Tomboy/RemoteControl",
+                                "org.gnome.Tomboy.RemoteControl")
+        
+    def initialize(self):
+        super(Backend, self).initialize()
+        with self.DbusWatchdog(self):
+            bus = dbus.SessionBus()
+            bus.add_signal_receiver(self.on_note_saved,
+                                    dbus_interface = self.BUS_ADDRESS[2],
+                                    signal_name    = "NoteSaved")
+            bus.add_signal_receiver(self.on_note_deleted,
+                                    dbus_interface = self.BUS_ADDRESS[2],
+                                    signal_name    = "NoteDeleted")
+
+    def start_get_tasks(self):
+        '''
+        Once this function is launched, the backend can start pushing
+        tasks to gtg parameters.
+        
+        @return: start_get_tasks() might not return or finish
+        '''
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                tomboy_notes = tomboy.ListAllNotes()
+        for note in tomboy_notes:
+            self._process_tomboy_note(note)
+
+    def save_state(self):
+        self._store_pickled_file(self.data_path, self.sync_engine)
+
+    def get_number_of_tasks(self):
+        '''
+        Returns the number of tasks stored in the backend. Doesn't need to be a
+        fast function, is called just for the UI
+        '''
+        return 42
+
+###############################################################################
+### Something got removed #####################################################
+###############################################################################
+
+    def on_note_deleted(self, note, something):
+        try:
+            tid = self.sync_engine.get_local_id(note)
+        except KeyError:
+            return
+        if self.datastore.has_task(tid):
+            self.datastore.request_task_deletion(tid)
+        try:
+            self.sync_engine.break_relationship(remote_id = note)
+        except:
+            pass
+
+    def remove_task(self, tid):
+        ''' Completely remove the task with ID = tid '''
+        try:
+            note = self.sync_engine.get_remote_id(tid)
+        except KeyError:
+            return
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                if tomboy.NoteExists(note):
+                    tomboy.DeleteNote(note)
+            try:
+                self.sync_engine.break_relationship(local_id = tid)
+            except:
+                pass
+
+    def _exec_lost_syncability(self, tid, note):
+        meme = self.sync_engine.get_meme_from_remote_id(note)
+        if meme.get_origin() == "GTG":
+            with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+                with self.DbusWatchdog(self):
+                    tomboy.DeleteNote(note)
+        else:
+            self.datastore.request_task_deletion(tid)
+        try:
+            self.sync_engine.break_relationship(remote_id = note)
+        except KeyError:
+            pass
+
+###############################################################################
+### Process tasks #############################################################
+###############################################################################
+
+    def _process_tomboy_note(self, note):
+        is_syncable = self._tomboy_note_is_syncable(note)
+        with self.DbusWatchdog(self):
+            action, tid = self.sync_engine.analyze_remote_id(note, \
+                     self.datastore.has_task, \
+                     self._tomboy_note_exists, is_syncable)
+        Log.debug("processing tomboy (%s, %d)" % (action, is_syncable))
+
+        if action == SyncEngine.ADD:
+            tid = str(uuid.uuid4())
+            task = self.datastore.task_factory(tid)
+            self._populate_task(task, note)
+            self.sync_engine.record_relationship(local_id = tid,\
+                           remote_id = str(note), \
+                           meme = self._new_meme(task, note, self.get_id()))
+            self.datastore.push_task(task)
+
+        elif action == SyncEngine.UPDATE:
+            task = self.datastore.get_task(tid)
+            meme = self.sync_engine.get_meme_from_remote_id(note)
+            newest = meme.which_is_newest(task.get_modified(),
+                                 self.get_modified_for_note(note))
+            if newest == "remote":
+                self._populate_task(task, note)
+                self._update_meme(meme, task, note)
+
+        elif action == SyncEngine.REMOVE:
+            with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+                with self.DbusWatchdog(self):
+                    tomboy.DeleteNote(note)
+                try:
+                    self.sync_engine.break_relationship(remote_id = note)
+                except KeyError:
+                    pass
+
+        elif action == SyncEngine.LOST_SYNCABILITY:
+            self._exec_lost_syncability(tid, note)
+
+    def set_task(self, task):
+        is_syncable = self._task_is_syncable(task)
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            tid = task.get_id()
+            with self.DbusWatchdog(self):
+                action, note = self.sync_engine.analyze_local_id(tid, \
+                          self.datastore.has_task, tomboy.NoteExists, \
+                                                        is_syncable)
+            Log.debug("processing gtg (%s, %d)" % (action, is_syncable))
+
+            if action == SyncEngine.ADD:
+                #GTG allows multiple tasks with the same name, Tomboy doesn't.
+                #we need to handle the renaming manually
+                title = task.get_title()
+                duplicate_counter = 1
+                with self.DbusWatchdog(self):
+                    note = tomboy.CreateNamedNote(title)
+                    while note == "":
+                        duplicate_counter += 1
+                        note = tomboy.CreateNamedNote(title + "(%d)" %
+                                                  duplicate_counter)
+                if duplicate_counter != 1:
+                    #if we needed to rename, we have to rename also the gtg task
+                    task.set_title(title + " (%d)" % duplicate_counter)
+
+                self.sync_engine.record_relationship( \
+                    local_id = tid, remote_id = str(note), \
+                    meme = self._new_meme(task, note, "GTG"))
+
+            elif action == SyncEngine.UPDATE:
+                meme = self.sync_engine.get_meme_from_local_id(task.get_id())
+                newest = meme.which_is_newest(task.get_modified(),
+                                     self.get_modified_for_note(note))
+                if newest == "local":
+                    self._populate_note(note, task)
+                    self._update_meme(meme, task, note)
+
+            elif action == SyncEngine.REMOVE:
+                self.datastore.request_task_deletion(tid)
+                try:
+                    self.sync_engine.break_relationship(local_id = tid)
+                except KeyError:
+                    pass
+
+            elif action == SyncEngine.LOST_SYNCABILITY:
+                self._exec_lost_syncability(tid, note)
+
+###############################################################################
+### Helper methods ############################################################
+###############################################################################
+
+    def on_note_saved(self,  note):
+        #it is necessary to queue these events as tomboy notes are not edited
+        # atomically: we need to give them some time to be filled with all the
+        # necessary data (an incomplete note might break a relationship with a
+        # task)
+        try:
+            self._tomboy_setting_timers[note].cancel()
+        except KeyError:
+            pass
+        finally:
+            timer =threading.Timer(10, self._execute_on_note_saved, args = [note])
+            self._tomboy_setting_timers[note] = timer
+            timer.start()
+
+
+    def _execute_on_note_saved(self, note):
+        try:
+            del self._tomboy_setting_timers[note]
+        except:
+            pass
+        self._process_tomboy_note(note)
+
+    def _tomboy_note_is_syncable(self, note):
+        attached_tags = self.get_attached_tags()
+        if GenericBackend.ALLTASKS_TAG in attached_tags:
+            return True
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                content = tomboy.GetNoteContents(note)
+            syncable = False
+            for tag in attached_tags:
+                try:
+                    content.index(tag)
+                    syncable = True
+                    break
+                except ValueError:
+                    pass
+            return syncable
+
+    def _task_is_syncable(self, task):
+        attached_tags = self.get_attached_tags()
+        if GenericBackend.ALLTASKS_TAG in attached_tags:
+            return True
+        for tag in task.get_tags_name():
+            if tag in attached_tags:
+                return  True
+        return False
+
+    def _tomboy_note_exists(self, note):
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                return tomboy.NoteExists(note)
+
+    def get_modified_for_note(self, note):
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                return datetime.datetime.fromtimestamp( \
+                                tomboy.GetNoteChangeDate(note))
+
+    def _tomboy_split_title_and_text(self, content):
+            try:
+                end_of_title = content.index('\n')
+            except ValueError:
+                return content, ""
+            title = content[: end_of_title]
+            if len(content) > end_of_title:
+                return title, content[end_of_title +1 :]
+            else:
+                return title, ""
+
+    def _populate_task(self, task, note):
+        #add tags objects (it's not enough to have @tag in the text to add a
+        # tag
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                content = tomboy.GetNoteContents(note)
+        #update the tags list
+        matches = re.finditer("(?<![^|\s])(@\w+)", content)
+        new_tags_list = [content[g.start() : g.end()] for g in matches]
+        for tag in task.get_tags_name():
+            try:
+                new_tags_list.remove(tag)
+                task.remove_tag(tag)
+            except:
+                task.add_tag(tag)
+        for tag in new_tags_list:
+            task.add_tag(tag)
+        #extract title and text
+        [title, text] = self._tomboy_split_title_and_text(content)
+        task.set_title(title)
+        task.set_text(text)
+        task.add_remote_id(self.get_id(), str(note))
+
+
+    def _populate_note(self, note, task):
+        title = task.get_title()
+        tested_title = title
+        duplicate_counter = 1
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                while tomboy.FindNote(tested_title) != '':
+                    duplicate_counter +=1
+                    tested_title = title + " (%d)" % duplicate_counter
+                tomboy.SetNoteContents(note, tested_title + '\n' + \
+                                   task.get_excerpt())
+
+    def _new_meme(self, task, note, origin):
+        meme = self._update_meme(SyncMeme(), task, note)
+        meme.set_origin(origin)
+        return meme
+        
+    def _update_meme(self, meme, task, note):
+        meme.set_local_last_modified(task.get_modified())
+        meme.set_remote_last_modified(self.get_modified_for_note(note))
+        return meme
+
+    def quit(self, disable = False):
+        def quit_thread():
+            while True:
+                try:
+                    [key, timer] = \
+                        self._tomboy_setting_timers.iteritems().next()
+                except StopIteration:
+                    break
+                timer.cancel()
+                del self._tomboy_setting_timers[key]
+        threading.Thread(target = quit_thread).start()
+        super(Backend, self).quit(disable)
+
+###############################################################################
+### Connection handling #######################################################
+###############################################################################
+
+
+
+    class TomboyConnection(Borg):
+        '''
+        TomboyConnection creates a connection to TOMBOY via DBUS and handles all
+        the possible exceptions.
+        It is a class that can be used with a with statement.
+        Example:
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            #do something
+        '''
+
+
+        def __init__(self, backend, bus_name, bus_path, bus_interface):
+            super(Backend.TomboyConnection, self).__init__()
+            if hasattr(self, "tomboy_connection_is_ok") and \
+                                self.tomboy_connection_is_ok:
+                return
+            self.backend = backend
+            with Backend.DbusWatchdog(backend):
+                bus = dbus.SessionBus()
+                obj = bus.get_object(bus_name, bus_path)
+                self.tomboy = dbus.Interface(obj, bus_interface)
+            self.tomboy_connection_is_ok = True
+
+        def __enter__(self):
+            return self.tomboy
+
+        def __exit__(self, type, value, traceback):
+            if value == None:
+                return True
+            if isinstance(value, dbus.DBusException):
+                self.tomboy_connection_is_ok = False
+                self.backend.quit(disable = True)
+                BackendSignals().backend_failed(self.backend.get_id(), \
+                            BackendSignals.ERRNO_DBUS)
+            else:
+                return False
+            return True
+
+
+
+    class DbusWatchdog(Watchdog):
+        '''
+        A simple watchdog to detect stale dbus connections
+        '''
+
+
+        def __init__(self, backend):
+            super(Backend.DbusWatchdog, self).__init__(10, \
+                                    self._when_taking_too_long)
+            self.backend = backend
+
+        def _when_taking_too_long(self):
+            self.backend.quit(disable = True)
+            BackendSignals().backend_failed(self.backend.get_id(), \
+                            BackendSignals.ERRNO_DBUS)
+

=== added file 'GTG/backends/syncengine.py'
--- GTG/backends/syncengine.py	1970-01-01 00:00:00 +0000
+++ GTG/backends/syncengine.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,115 @@
+from GTG.tools.twokeydict import TwoKeyDict
+
+
+TYPE_LOCAL = "local"
+TYPE_REMOTE = "remote"
+
+
+
+class SyncMeme(object):
+
+
+    #FIXME: Add checking for crcs to make this stronger
+    def set_local_last_modified(self, modified_datetime):
+        self.local_last_modified = modified_datetime
+
+    def get_local_last_modified(self):
+        return self.local_last_modified
+
+    def set_remote_last_modified(self, modified_datetime):
+        self.remote_last_modified = modified_datetime
+
+    def get_remote_last_modified(self):
+        return self.remote_last_modified
+
+    def which_is_newest(self, local_modified, remote_modified):
+        if local_modified <= self.local_last_modified and \
+                remote_modified <= self.remote_last_modified:
+            return None
+        if local_modified > remote_modified:
+            return "local"
+        else:
+            return "remote"
+    
+    def get_origin(self):
+        '''
+        Returns the name of the source that firstly presented the object
+        '''
+        return self.origin
+
+    def set_origin(self, origin):
+        self.origin = origin
+
+
+
+class SyncMemes(TwoKeyDict):
+
+
+    get_remote_id = TwoKeyDict._get_secondary_key
+    get_local_id = TwoKeyDict._get_primary_key
+    remove_local_id = TwoKeyDict._remove_by_primary
+    remove_remote_id = TwoKeyDict._remove_by_secondary
+    get_meme_from_local_id = TwoKeyDict._get_by_primary
+    get_meme_from_remote_id = TwoKeyDict._get_by_secondary
+
+
+
+class SyncEngine(object):
+
+
+    UPDATE = "update"
+    REMOVE = "remove"
+    ADD    = "add"
+    LOST_SYNCABILITY = "lost syncability"
+
+    def __init__(self):
+        self.sync_memes = SyncMemes()
+
+    def _analyze_element(self, element_id, is_local, \
+                         has_local, has_remote, is_syncable = True):
+        if is_local:
+            get_other_id = self.sync_memes.get_remote_id
+            is_task_present = has_remote
+        else:
+            get_other_id = self.sync_memes.get_local_id
+            is_task_present = has_local
+
+        try:
+            other_id = get_other_id(element_id)
+            if is_task_present(other_id):
+                if is_syncable:
+                    return self.UPDATE, other_id
+                else:
+                    return self.LOST_SYNCABILITY, other_id
+            else:
+                return self.REMOVE, None
+        except KeyError:
+            if is_syncable:
+                return self.ADD, None
+            return None, None
+
+    def analyze_local_id(self, element_id, *other_args):
+        return self._analyze_element(element_id, True, *other_args)
+
+    def analyze_remote_id(self, element_id, *other_args):
+        return self._analyze_element(element_id, False, *other_args)
+    
+    def record_relationship(self, local_id, remote_id, meme):
+        triplet = (local_id, remote_id, meme)
+        self.sync_memes.add(triplet)
+
+    def break_relationship(self, local_id = None, remote_id = None):
+        if local_id:
+            self.sync_memes.remove_local_id(local_id)
+        elif remote_id:
+            self.sync_memes.remove_remote_id(remote_id)
+
+    def __getattr__(self, attr):
+        if attr in ['get_remote_id', \
+                    'get_local_id',
+                    'get_meme_from_local_id',
+                    'get_meme_from_remote_id']:
+            return getattr(self.sync_memes, attr)
+        else:
+            raise AttributeError
+

=== modified file 'GTG/gtk/delete_dialog.py'
--- GTG/gtk/delete_dialog.py	2010-06-23 01:17:32 +0000
+++ GTG/gtk/delete_dialog.py	2010-06-23 01:17:32 +0000
@@ -19,17 +19,12 @@
 # -----------------------------------------------------------------------------
 import gtk
 
-from GTG     import _, ngettext
+from GTG     import _
 from GTG.gtk import ViewConfig
 
 
-
 class DeletionUI():
-
-    
-    MAXIMUM_TIDS_TO_SHOW = 20
-
-    def __init__(self, req):
+    def __init__(self,req):
         self.req = req
         self.tids_todelete = []
         # Load window tree
@@ -62,28 +57,19 @@
             cdlabel2 = self.builder.get_object("cd-label2")
             cdlabel3 = self.builder.get_object("cd-label3")
             cdlabel4 = self.builder.get_object("cd-label4")
-            singular = len(self.tids_todelete)
-            label_text = ngettext("Deleting a task cannot be undone, "
-                                  "and will delete the following tasks: ",
-                                  "Deleting a task cannot be undone, "
-                                  "and will delete the following task: ",
-                                  singular)
-            cdlabel2.set_label(ngettext("Are you sure you want to delete these "
-                                       "tasks?",
-                                       "Are you sure you want to delete this "
-                                       "task?",
-                                       singular))
-
-            cdlabel3.set_label(ngettext("Keep selected tasks",
-                                        "Keep selected task",
-                                       singular))
-            cdlabel4.set_label(ngettext("Permanently remove tasks",
-                                       "Permanently remove task",
-                                       singular))
+            if len(self.tids_todelete) == 1:
+                label_text = _("Deleting a task cannot be undone, and will delete the following task: ")
+                cdlabel2.set_label(_("Are you sure you want to delete this task?"))
+                cdlabel3.set_label(_("Keep selected task"))
+                cdlabel4.set_label(_("Permanently remove task"))
+            else:
+                label_text = _("Deleting a task cannot be undone, and will delete the following tasks: ")
+                cdlabel2.set_label(_("Are you sure you want to delete these tasks?"))
+                cdlabel3.set_label(_("Keep selected tasks"))
+                cdlabel4.set_label(_("Permanently remove tasks"))
             label_text = label_text[0:label_text.find(":") + 1]
             
-            # I find the tasks that are going to be deleted, avoiding to fetch
-            # too many titles (no more that the maximum capacity)
+            # I find the tasks that are going to be deleted
             tasks = []
             for tid in self.tids_todelete:
                 def recursive_list_tasks(task_list, root):
@@ -91,24 +77,12 @@
                        their children, recursively"""
                     if root not in task_list:
                         task_list.append(root)
-                        if len(task_list) >= self.MAXIMUM_TIDS_TO_SHOW:
-                            return
                         for i in root.get_subtasks():
                             recursive_list_tasks(task_list, i)
                 task = self.req.get_task(tid)
                 recursive_list_tasks(tasks, task)
-                len_tasks = len(tasks)
-                #we don't want to end with just one task that doesn't fit the
-                # screen and a line saying "And one more task", so we go a
-                # little over our limit
-                if len_tasks > self.MAXIMUM_TIDS_TO_SHOW + 2:
-                    tasks = tasks[: self.MAXIMUM_TIDS_TO_SHOW + 2]
-                    break
             titles_list = [task.get_title() for task in tasks]
             titles = reduce (lambda x, y: x + "\n - " + y, titles_list)
-            missing_titles_count = len(self.tids_todelete) - len_tasks
-            if missing_titles_count > 2:
-               titles += _("\nAnd %d more tasks" % missing_titles_count)
             label.set_text("%s %s" % (label_text, "\n - " + titles))
             delete_dialog = self.builder.get_object("confirm_delete")
             delete_dialog.resize(1, 1)

=== added file 'GTG/tests/test_backend_tomboy.py'
--- GTG/tests/test_backend_tomboy.py	1970-01-01 00:00:00 +0000
+++ GTG/tests/test_backend_tomboy.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,401 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Gettings Things Gnome! - a personal organizer for the GNOME desktop
+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program.  If not, see <http://www.gnu.org/licenses/>.
+# -----------------------------------------------------------------------------
+
+'''
+Tests for the tomboy backend
+'''
+
+import os
+import sys
+import errno
+import unittest
+import uuid
+import signal
+import time
+import math
+import dbus
+import gobject
+import random
+import threading
+import dbus.glib
+import dbus.service
+import tempfile
+import datetime
+from datetime           import datetime
+from dbus.mainloop.glib import DBusGMainLoop
+
+from GTG.core.datastore          import DataStore
+from GTG.backends                import BackendFactory
+from GTG.backends.genericbackend import GenericBackend
+from GTG.core                    import CoreConfig
+
+
+
+class TestBackendTomboy(unittest.TestCase):
+    '''
+    Tests for the tomboy backend.
+    '''
+    
+
+    def setUp(self):
+        self.spawn_fake_tomboy_server()
+        #only the test process should go further, the dbus server one should
+        #stop here
+        if not self.child_process_pid: return
+        #we create a custom dictionary listening to the server, and register it
+        # in GTG. 
+        additional_dic = {}
+        additional_dic["use this fake connection instead"] = \
+                (FakeTomboy.BUS_NAME,
+                 FakeTomboy.BUS_PATH,
+                 FakeTomboy.BUS_INTERFACE)
+        additional_dic[GenericBackend.KEY_ATTACHED_TAGS] = \
+                        [GenericBackend.ALLTASKS_TAG]
+        additional_dic[GenericBackend.KEY_DEFAULT_BACKEND] = True
+        dic = BackendFactory().get_new_backend_dict('backend_tomboy', 
+                                                   additional_dic)
+        self.datastore = DataStore()
+        self.backend = self.datastore.register_backend(dic)
+        #waiting for the "start_get_tasks" to settle
+        time.sleep(1)
+        #we create a dbus session to speak with the server
+        self.bus = dbus.SessionBus()
+        obj = self.bus.get_object(FakeTomboy.BUS_NAME, FakeTomboy.BUS_PATH)
+        self.tomboy = dbus.Interface(obj, FakeTomboy.BUS_INTERFACE)
+        
+    def spawn_fake_tomboy_server(self):
+        #the fake tomboy server has to be in a different process,
+        #otherwise it will lock on the GIL.
+        #For details, see
+        #http://lists.freedesktop.org/archives/dbus/2007-January/006921.html
+
+        #we use a lockfile to make sure the server is running before we start
+        # the test
+        lockfile_fd, lockfile_path = tempfile.mkstemp()
+        self.child_process_pid = os.fork()
+        if self.child_process_pid:
+            #we wait in polling that the server has been started
+            while True:
+                try:
+                    fd = os.open(lockfile_path, os.O_CREAT | os.O_EXCL | os.O_RDWR)
+                except OSError, e:
+                    if e.errno != errno.EEXIST:
+                        raise
+                    time.sleep(0.3)
+                    continue
+                os.close(fd)
+                break
+        else:
+            FakeTomboy()
+            os.close(lockfile_fd)
+            os.unlink(lockfile_path)
+
+    def tearDown(self):
+        if not self.child_process_pid: return
+        self.datastore.save(quit = True) 
+        time.sleep(0.5)
+        self.tomboy.FakeQuit()
+        self.bus.close()
+        os.kill(self.child_process_pid, signal.SIGKILL)
+        os.waitpid(self.child_process_pid, 0)
+    
+    def test_everything(self):
+        '''
+        '''
+        #we cannot use separate test functions because we only want a single
+        # FakeTomboy dbus server running
+        if not self.child_process_pid: return
+        for function in dir(self):
+            if function.startswith("TEST_"):
+                getattr(self, function)()
+                self.tomboy.Reset()
+                for tid in self.datastore.get_all_tasks():
+                    self.datastore.request_task_deletion(tid)
+                time.sleep(0.1)
+
+    def TEST_processing_tomboy_notes(self):
+        self.backend.set_attached_tags([GenericBackend.ALLTASKS_TAG])
+        #adding a note
+        note = self.tomboy.CreateNamedNote(str(uuid.uuid4()))
+        self.backend._process_tomboy_note(note)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 1)
+        tid = self.backend.sync_engine.sync_memes.get_local_id(note)
+        task = self.datastore.get_task(tid)
+        #re-adding that (should not change anything)
+        self.backend._process_tomboy_note(note)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 1)
+        self.assertEqual( \
+                self.backend.sync_engine.sync_memes.get_local_id(note), tid)
+        #removing the note and updating gtg
+        self.tomboy.DeleteNote(note)
+        self.backend.set_task(task)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 0)
+
+    def TEST_set_task(self):
+        self.backend.set_attached_tags([GenericBackend.ALLTASKS_TAG])
+        #adding a task
+        task = self.datastore.requester.new_task()
+        task.set_title("title")
+        self.backend.set_task(task)
+        self.assertEqual(len(self.tomboy.ListAllNotes()), 1)
+        note = self.tomboy.ListAllNotes()[0]
+        self.assertEqual(str(self.tomboy.GetNoteTitle(note)), task.get_title())
+        #re-adding that (should not change anything)
+        self.backend.set_task(task)
+        self.assertEqual(len(self.tomboy.ListAllNotes()), 1)
+        self.assertEqual(note, self.tomboy.ListAllNotes()[0])
+        #removing the task and updating tomboy
+        self.datastore.request_task_deletion(task.get_id())
+        self.backend._process_tomboy_note(note)
+        self.assertEqual(len(self.tomboy.ListAllNotes()), 0)
+
+    def TEST_update_newest(self):
+        self.backend.set_attached_tags([GenericBackend.ALLTASKS_TAG])
+        task = self.datastore.requester.new_task()
+        task.set_title("title")
+        self.backend.set_task(task)
+        note = self.tomboy.ListAllNotes()[0]
+        gtg_modified = task.get_modified()
+        tomboy_modified = self._modified_string_to_datetime( \
+                    self.tomboy.GetNoteChangeDate(note))
+        #no-one updated, nothing should happen
+        self.backend.set_task(task)
+        self.assertEqual(gtg_modified, task.get_modified())
+        self.assertEqual(tomboy_modified, \
+                         self._modified_string_to_datetime( \
+                         self.tomboy.GetNoteChangeDate(note)))
+        #we update the GTG task
+        UPDATED_GTG_TITLE = "UPDATED_GTG_TITLE"
+        task.set_title(UPDATED_GTG_TITLE)
+        self.backend.set_task(task)
+        self.assertTrue(gtg_modified < task.get_modified())
+        self.assertTrue(tomboy_modified <=
+                         self._modified_string_to_datetime( \
+                         self.tomboy.GetNoteChangeDate(note)))
+        self.assertEqual(task.get_title(), UPDATED_GTG_TITLE)
+        self.assertEqual(self.tomboy.GetNoteTitle(note), UPDATED_GTG_TITLE)
+        gtg_modified = task.get_modified()
+        tomboy_modified = self._modified_string_to_datetime( \
+                    self.tomboy.GetNoteChangeDate(note))
+        #we update the TOMBOY task
+        UPDATED_TOMBOY_TITLE = "UPDATED_TOMBOY_TITLE"
+        #the resolution of tomboy notes changed time is 1 second, so we need
+        # to wait. This *shouldn't* be needed in the actual code because
+        # tomboy signals are always a few seconds late.
+        time.sleep(1)
+        self.tomboy.SetNoteContents(note, UPDATED_TOMBOY_TITLE)
+        self.backend._process_tomboy_note(note)
+        self.assertTrue(gtg_modified <= task.get_modified())
+        self.assertTrue(tomboy_modified <=
+                         self._modified_string_to_datetime( \
+                         self.tomboy.GetNoteChangeDate(note)))
+        self.assertEqual(task.get_title(), UPDATED_TOMBOY_TITLE)
+        self.assertEqual(self.tomboy.GetNoteTitle(note), UPDATED_TOMBOY_TITLE)
+
+    def TEST_processing_tomboy_notes_with_tags(self):
+        self.backend.set_attached_tags(['@a'])
+        #adding a not syncable note
+        note = self.tomboy.CreateNamedNote("title" + str(uuid.uuid4()))
+        self.backend._process_tomboy_note(note)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 0)
+        #re-adding that (should not change anything)
+        self.backend._process_tomboy_note(note)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 0)
+        #adding a tag to that note
+        self.tomboy.SetNoteContents(note, "something with @a")
+        self.backend._process_tomboy_note(note)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 1)
+        #removing the tag and resyncing
+        self.tomboy.SetNoteContents(note, "something with no tags")
+        self.backend._process_tomboy_note(note)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 0)
+        #adding a syncable note
+        note = self.tomboy.CreateNamedNote("title @a" + str(uuid.uuid4()))
+        self.backend._process_tomboy_note(note)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 1)
+        tid = self.backend.sync_engine.sync_memes.get_local_id(note)
+        task = self.datastore.get_task(tid)
+        #re-adding that (should not change anything)
+        self.backend._process_tomboy_note(note)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 1)
+        self.assertEqual( \
+                self.backend.sync_engine.sync_memes.get_local_id(note), tid)
+        #removing the note and updating gtg
+        self.tomboy.DeleteNote(note)
+        self.backend.set_task(task)
+        self.assertEqual(len(self.datastore.get_all_tasks()), 0)
+
+    def TEST_set_task_with_tags(self):
+        self.backend.set_attached_tags(['@a'])
+        #adding a not syncable task
+        task = self.datastore.requester.new_task()
+        task.set_title("title")
+        self.backend.set_task(task)
+        self.assertEqual(len(self.tomboy.ListAllNotes()), 0)
+        #making that task  syncable 
+        task.set_title("something else")
+        task.add_tag("@a")
+        self.backend.set_task(task)
+        self.assertEqual(len(self.tomboy.ListAllNotes()),  1)
+        note = self.tomboy.ListAllNotes()[0]
+        self.assertEqual(str(self.tomboy.GetNoteTitle(note)), task.get_title())
+        #re-adding that (should not change anything)
+        self.backend.set_task(task)
+        self.assertEqual(len(self.tomboy.ListAllNotes()), 1)
+        self.assertEqual(note, self.tomboy.ListAllNotes()[0])
+        #removing the syncable property and updating tomboy
+        task.remove_tag("@a")
+        self.backend.set_task(task)
+        self.assertEqual(len(self.tomboy.ListAllNotes()), 0)
+
+    def TEST_multiple_task_same_title(self):
+        self.backend.set_attached_tags(['@a'])
+        how_many_tasks = int(math.ceil(20 * random.random()))
+        for iteration in xrange(0, how_many_tasks):
+            task = self.datastore.requester.new_task()
+            task.set_title("title")
+            task.add_tag('@a')
+            self.backend.set_task(task)
+        self.assertEqual(len(self.tomboy.ListAllNotes()), how_many_tasks)
+
+    def _modified_string_to_datetime(self, modified_string):
+        return datetime.fromtimestamp(modified_string)
+
+
+def test_suite():
+    CoreConfig().set_data_dir("./test_data")
+    CoreConfig().set_conf_dir("./test_data")
+    return unittest.TestLoader().loadTestsFromTestCase(TestBackendTomboy)
+
+
+
+
+class FakeTomboy(dbus.service.Object):
+    """
+    D-Bus service object that mimics TOMBOY
+    """
+
+
+    #We don't directly use the tomboy dbus path to avoid conflicts
+    # if tomboy is running during the test
+
+    BUS_NAME = "Fake.Tomboy"
+    BUS_PATH = "/Fake/Tomboy"
+    BUS_INTERFACE = "Fake.Tomboy.RemoteControl"
+
+    def __init__(self):
+        # Attach the object to D-Bus
+        DBusGMainLoop(set_as_default=True)
+        self.bus = dbus.SessionBus() 
+        bus_name = dbus.service.BusName(self.BUS_NAME, bus = self.bus)
+        dbus.service.Object.__init__(self, bus_name, self.BUS_PATH)
+        self.notes = {}
+        threading.Thread(target = self.fake_main_loop).start()
+
+    @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="s")
+    def GetNoteContents(self, note):
+        return self.notes[note]['content']
+
+    @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="b")
+    def NoteExists(self, note):
+        return self.notes.has_key(note)
+
+    @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="d")
+    def GetNoteChangeDate(self, note):
+        return self.notes[note]['changed']
+
+    @dbus.service.method(BUS_INTERFACE, in_signature="ss")
+    def SetNoteContents(self, note, text):
+        self.fake_update_note(note)
+        self.notes[note]['content'] = text
+
+    @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="s")
+    def GetNoteTitle(self, note):
+        return self._GetNoteTitle(note)
+
+    def _GetNoteTitle(self, note):
+        content = self.notes[note]['content']
+        try:
+            end_of_title = content.index('\n')
+        except ValueError:
+            return content
+        return content[:end_of_title]
+
+    @dbus.service.method(BUS_INTERFACE, in_signature="s")
+    def DeleteNote(self, note):
+        del self.notes[note]
+
+    @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="s")
+    def CreateNamedNote(self, title):
+        #this is to mimic the way tomboy handles title clashes
+        if self._FindNote(title) != '':
+            return ''
+        note = str(uuid.uuid4())
+        self.notes[note] = {'content': title}
+        self.fake_update_note(note)
+        return note
+
+    @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="s")
+    def FindNote(self, title):
+        return self._FindNote(title)
+
+    def _FindNote(self, title):
+        for note in self.notes:
+            if self._GetNoteTitle(note) == title:
+                return note
+        return ''
+
+    @dbus.service.method(BUS_INTERFACE, out_signature = "as")
+    def ListAllNotes(self):
+        return list(self.notes)
+    
+    @dbus.service.signal(BUS_INTERFACE, signature='s')
+    def NoteSaved(self, note):
+        pass
+
+    @dbus.service.signal(BUS_INTERFACE, signature='s')
+    def NoteDeleted(self, note):
+        pass
+
+###############################################################################
+### Function with the fake_ prefix are here to assist in testing, they do not
+### need to be present in the real class
+###############################################################################
+
+    def fake_update_note(self, note):
+        self.notes[note]['changed'] = time.mktime(datetime.now().timetuple())
+
+    def fake_main_loop(self):
+        gobject.threads_init()
+        dbus.glib.init_threads()
+        self.main_loop = gobject.MainLoop()
+        self.main_loop.run()
+
+    @dbus.service.method(BUS_INTERFACE)
+    def Reset(self):
+        self.notes = {}
+
+    @dbus.service.method(BUS_INTERFACE)
+    def FakeQuit(self):
+        threading.Timer(0.2, self._fake_quit).start()
+
+    def _fake_quit(self):
+        self.main_loop.quit()
+        sys.exit(0)
+

=== added file 'GTG/tests/test_bidict.py'
--- GTG/tests/test_bidict.py	1970-01-01 00:00:00 +0000
+++ GTG/tests/test_bidict.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Gettings Things Gnome! - a personal organizer for the GNOME desktop
+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program.  If not, see <http://www.gnu.org/licenses/>.
+# -----------------------------------------------------------------------------
+
+'''
+Tests for the datastore
+'''
+
+import unittest
+import uuid
+
+from GTG.tools.bidict import BiDict
+from GTG.core import CoreConfig
+
+
+
+class TestBiDict(unittest.TestCase):
+    '''
+    Tests for the BiDict object.
+    '''
+
+    
+    def test_add_and_gets(self):
+        '''
+        Test for the __init__, _get_by_first, _get_by_second function
+        '''
+        pairs = [(uuid.uuid4(), uuid.uuid4()) for a in xrange(10)]
+        bidict = BiDict(*pairs)
+        for pair in pairs:
+            self.assertEqual(bidict._get_by_first(pair[0]), pair[1])
+            self.assertEqual(bidict._get_by_second(pair[1]), pair[0])
+
+    def test_remove_by_first_or_second(self):
+        pair_first = (1, 'one')
+        pair_second = (2, 'two')
+        bidict = BiDict(pair_first, pair_second)
+        bidict._remove_by_first(pair_first[0])
+        bidict._remove_by_second(pair_second[1])
+        missing_first = 0
+        missing_second = 0
+        try:
+            bidict._get_by_first(pair_first[0])
+        except KeyError:
+            missing_first += 1
+        try:
+            bidict._get_by_first(pair_second[0])
+        except KeyError:
+            missing_first += 1
+        try:
+            bidict._get_by_second(pair_first[1])
+        except KeyError:
+            missing_second += 1
+        try:
+            bidict._get_by_second(pair_second[1])
+        except KeyError:
+            missing_second += 1
+        self.assertEqual(missing_first, 2)
+        self.assertEqual(missing_second, 2)
+
+def test_suite():
+    CoreConfig().set_data_dir("./test_data")
+    CoreConfig().set_conf_dir("./test_data")
+    return unittest.TestLoader().loadTestsFromTestCase(TestBiDict)
+
+
+

=== added file 'GTG/tests/test_syncengine.py'
--- GTG/tests/test_syncengine.py	1970-01-01 00:00:00 +0000
+++ GTG/tests/test_syncengine.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,192 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Gettings Things Gnome! - a personal organizer for the GNOME desktop
+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program.  If not, see <http://www.gnu.org/licenses/>.
+# -----------------------------------------------------------------------------
+
+'''
+Tests for the SyncEngine class
+'''
+
+import unittest
+import uuid
+
+from GTG.backends.syncengine import SyncEngine
+from GTG.core import CoreConfig
+
+
+
+class TestSyncEngine(unittest.TestCase):
+    '''
+    Tests for the SyncEngine object.
+    '''
+    
+    def setUp(self):
+        self.ftp_local = FakeTaskProvider()
+        self.ftp_remote = FakeTaskProvider()
+        self.sync_engine = SyncEngine()
+    
+    def test_analyze_element_and_record_and_break_relationship(self):
+        '''
+        Test for the _analyze_element, analyze_remote_id, analyze_local_id,
+        record_relationship, break_relationship
+        '''
+        #adding a new local task
+        local_id = uuid.uuid4()
+        self.ftp_local.fake_add_task(local_id)
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.ADD, None))
+        #creating the related remote task
+        remote_id = uuid.uuid4()
+        self.ftp_remote.fake_add_task(remote_id)
+        #informing the sync_engine about that
+        self.sync_engine.record_relationship(local_id, remote_id, object())
+        #verifying that it understood that
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.UPDATE, remote_id))
+        self.assertEqual(self.sync_engine.analyze_remote_id(remote_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.UPDATE, local_id))
+        #and not the reverse
+        self.assertEqual(self.sync_engine.analyze_remote_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.ADD, None))
+        self.assertEqual(self.sync_engine.analyze_local_id(remote_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.ADD, None))
+        #now we remove the remote task
+        self.ftp_remote.fake_remove_task(remote_id)
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.REMOVE, None))
+        self.sync_engine.break_relationship(local_id = local_id)
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.ADD, None))
+        self.assertEqual(self.sync_engine.analyze_remote_id(remote_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.ADD, None))
+        #we add them back and remove giving the remote id as key to find what to
+        #delete
+        self.ftp_local.fake_add_task(local_id)
+        self.ftp_remote.fake_add_task(remote_id)
+        self.ftp_remote.fake_remove_task(remote_id)
+        self.sync_engine.record_relationship(local_id, remote_id, object)
+        self.sync_engine.break_relationship(remote_id = remote_id)
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.ADD, None))
+        self.assertEqual(self.sync_engine.analyze_remote_id(remote_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.ADD, None))
+
+    def test_syncability(self):
+        '''
+        Test for the _analyze_element, analyze_remote_id, analyze_local_id.
+        Checks that the is_syncable parameter is used correctly
+        '''
+        #adding a new local task unsyncable
+        local_id = uuid.uuid4()
+        self.ftp_local.fake_add_task(local_id)
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task,
+                                                           False), \
+                         (None, None))
+        #adding a new local task, syncable
+        local_id = uuid.uuid4()
+        self.ftp_local.fake_add_task(local_id)
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task), \
+                         (SyncEngine.ADD, None))
+        #creating the related remote task
+        remote_id = uuid.uuid4()
+        self.ftp_remote.fake_add_task(remote_id)
+        #informing the sync_engine about that
+        self.sync_engine.record_relationship(local_id, remote_id, object())
+        #checking that it behaves correctly with established relationships
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task,
+                                                          True), \
+                         (SyncEngine.UPDATE, remote_id))
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task,
+                                                          False), \
+                         (SyncEngine.LOST_SYNCABILITY, remote_id))
+        self.assertEqual(self.sync_engine.analyze_remote_id(remote_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task,
+                                                           True), \
+                         (SyncEngine.UPDATE, local_id))
+        self.assertEqual(self.sync_engine.analyze_remote_id(remote_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task,
+                                                           False), \
+                         (SyncEngine.LOST_SYNCABILITY, local_id))
+        #now we remove the remote task
+        self.ftp_remote.fake_remove_task(remote_id)
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task, 
+                                                          True), \
+                         (SyncEngine.REMOVE, None))
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task, 
+                                                          False), \
+                         (SyncEngine.REMOVE, None))
+        self.sync_engine.break_relationship(local_id = local_id)
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task,
+                                                          True), \
+                         (SyncEngine.ADD, None))
+        self.assertEqual(self.sync_engine.analyze_local_id(local_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task,
+                                                          False), \
+                         (None, None))
+        self.assertEqual(self.sync_engine.analyze_remote_id(remote_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task,
+                                                           True), \
+                         (SyncEngine.ADD, None))
+        self.assertEqual(self.sync_engine.analyze_remote_id(remote_id, \
+                       self.ftp_local.has_task, self.ftp_remote.has_task,
+                                                           False), \
+                         (None, None))
+
+def test_suite():
+    CoreConfig().set_data_dir("./test_data")
+    CoreConfig().set_conf_dir("./test_data")
+    return unittest.TestLoader().loadTestsFromTestCase(TestSyncEngine)
+
+
+class FakeTaskProvider(object):
+
+    def __init__(self):
+        self.dic = {}
+
+    def has_task(self, tid):
+        return self.dic.has_key(tid)
+
+###############################################################################
+### Function with the fake_ prefix are here to assist in testing, they do not
+### need to be present in the real class
+###############################################################################
+
+    def fake_add_task(self, tid):
+        self.dic[tid] = "something"
+
+    def fake_get_task(self, tid):
+        return self.dic[tid]
+
+    def fake_remove_task(self, tid):
+        del self.dic[tid]

=== added file 'GTG/tests/test_syncmeme.py'
--- GTG/tests/test_syncmeme.py	1970-01-01 00:00:00 +0000
+++ GTG/tests/test_syncmeme.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Gettings Things Gnome! - a personal organizer for the GNOME desktop
+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program.  If not, see <http://www.gnu.org/licenses/>.
+# -----------------------------------------------------------------------------
+
+'''
+Tests for the SyncMeme class
+'''
+
+import unittest
+import datetime
+
+from GTG.backends.syncengine import SyncMeme
+from GTG.core import CoreConfig
+
+
+
+class TestSyncMeme(unittest.TestCase):
+    '''
+    Tests for the SyncEngine object.
+    '''
+    
+    def test_which_is_newest(self):
+        '''
+        test the which_is_newest function
+
+        '''
+        meme = SyncMeme()
+        #tasks have not changed
+        local_modified = datetime.datetime.now()
+        remote_modified = datetime.datetime.now()
+        meme.set_local_last_modified(local_modified)
+        meme.set_remote_last_modified(remote_modified)
+        self.assertEqual(meme.which_is_newest(local_modified, \
+                                              remote_modified), None)
+        #we update the local
+        local_modified = datetime.datetime.now()
+        self.assertEqual(meme.which_is_newest(local_modified, \
+                                              remote_modified), 'local')
+        #we update the remote
+        remote_modified = datetime.datetime.now()
+        self.assertEqual(meme.which_is_newest(local_modified, \
+                                              remote_modified), 'remote')
+def test_suite():
+    CoreConfig().set_data_dir("./test_data")
+    CoreConfig().set_conf_dir("./test_data")
+    return unittest.TestLoader().loadTestsFromTestCase(TestSyncMeme)
+

=== added file 'GTG/tests/test_twokeydict.py'
--- GTG/tests/test_twokeydict.py	1970-01-01 00:00:00 +0000
+++ GTG/tests/test_twokeydict.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Gettings Things Gnome! - a personal organizer for the GNOME desktop
+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program.  If not, see <http://www.gnu.org/licenses/>.
+# -----------------------------------------------------------------------------
+
+'''
+Tests for the TwoKeyDict class
+'''
+
+import unittest
+import uuid
+
+from GTG.tools.twokeydict import TwoKeyDict
+from GTG.core import CoreConfig
+
+
+
+class TestTwoKeyDict(unittest.TestCase):
+    '''
+    Tests for the TwoKeyDict object.
+    '''
+
+    
+    def test_add_and_gets(self):
+        '''
+        Test for the __init__, _get_by_first, _get_by_second function
+        '''
+        triplets = [(uuid.uuid4(), uuid.uuid4(), uuid.uuid4()) \
+                    for a in xrange(10)]
+        tw_dict = TwoKeyDict(*triplets)
+        for triplet in triplets:
+            self.assertEqual(tw_dict._get_by_primary(triplet[0]), triplet[2])
+            self.assertEqual(tw_dict._get_by_secondary(triplet[1]), triplet[2])
+
+    def test_remove_by_first_or_second(self):
+        triplet_first = (1, 'I', 'one')
+        triplet_second = (2, 'II', 'two')
+        tw_dict = TwoKeyDict(triplet_first, triplet_second)
+        tw_dict._remove_by_primary(triplet_first[0])
+        tw_dict._remove_by_secondary(triplet_second[1])
+        missing_first = 0
+        missing_second = 0
+        try:
+            tw_dict._get_by_primary(triplet_first[0])
+        except KeyError:
+            missing_first += 1
+        try:
+            tw_dict._get_by_secondary(triplet_second[0])
+        except KeyError:
+            missing_first += 1
+        try:
+            tw_dict._get_by_secondary(triplet_first[1])
+        except KeyError:
+            missing_second += 1
+        try:
+            tw_dict._get_by_secondary(triplet_second[1])
+        except KeyError:
+            missing_second += 1
+        self.assertEqual(missing_first, 2)
+        self.assertEqual(missing_second, 2)
+        #check for memory leaks
+        dict_len = 0
+        for key in tw_dict._primary_to_value.iterkeys():
+            dict_len += 1
+        self.assertEqual(dict_len, 0)
+
+    def test_get_primary_and_secondary_key(self):
+        triplets = [(uuid.uuid4(), uuid.uuid4(), uuid.uuid4()) \
+                    for a in xrange(10)]
+        tw_dict = TwoKeyDict(*triplets)
+        for triplet in triplets:
+            self.assertEqual(tw_dict._get_secondary_key(triplet[0]), \
+                             triplet[1])
+            self.assertEqual(tw_dict._get_primary_key(triplet[1]), \
+                             triplet[0])
+
+def test_suite():
+    CoreConfig().set_data_dir("./test_data")
+    CoreConfig().set_conf_dir("./test_data")
+    return unittest.TestLoader().loadTestsFromTestCase(TestTwoKeyDict)
+

=== added file 'GTG/tools/bidict.py'
--- GTG/tools/bidict.py	1970-01-01 00:00:00 +0000
+++ GTG/tools/bidict.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,53 @@
+
+
+
+class BiDict(object):
+
+
+    '''
+    Bidirectional dictionary: the pairs stored can be accessed using either the
+    first or the second element as key.
+    If no special behaviour is coded, it defaults to a normal dict indexed on
+    the first value.
+    You don't need this if there is no clash between the domains of the first
+    and second element of the pairs.
+    '''
+
+    def __init__(self, *pairs):
+        super(BiDict, self).__init__()
+        self._first_to_second = {}
+        self._second_to_first = {}
+        for pair in pairs:
+            self.add(pair)
+
+    def add(self, pair):
+        self._first_to_second[pair[0]] = pair[1]
+        self._second_to_first[pair[1]] = pair[0]
+
+    def _get_by_first(self, key):
+        return self._first_to_second[key]
+
+    def _get_by_second(self, key):
+        return self._second_to_first[key]
+
+    def _remove_by_first(self, first):
+        second = self._first_to_second[first]
+        del self._second_to_first[second]
+        del self._first_to_second[first]
+
+    def _remove_by_second(self, second):
+        first = self._second_to_first[second]
+        del self._first_to_second[first]
+        del self._second_to_first[second]
+
+    def __str__(self):
+        return reduce(lambda text, keys: \
+                      str(text) + str(keys),
+                      self._first_to_second.iteritems())
+
+    def __getattr__(self, attr):
+        if attr in self.__dict__:
+            return self.__dict__[attr]
+        else:
+            return getattr(self._first_to_second, attr)
+

=== added file 'GTG/tools/networkmanager.py'
--- GTG/tools/networkmanager.py	1970-01-01 00:00:00 +0000
+++ GTG/tools/networkmanager.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,73 @@
+#!/bin/env python
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+# Copyright (C) 2010 Red Hat, Inc.
+#
+
+import dbus
+
+
+def is_connection_up():
+    state = False
+    bus = dbus.SystemBus()
+
+    proxy = bus.get_object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager")
+    manager = dbus.Interface(proxy, "org.freedesktop.NetworkManager")
+
+# Get device-specific state
+#    devices = manager.GetDevices()
+#    for d in devices:
+#        dev_proxy = bus.get_object("org.freedesktop.NetworkManager", d)
+#        prop_iface = dbus.Interface(dev_proxy, "org.freedesktop.DBus.Properties")
+#
+#        # Get the device's current state and interface name
+#        state = prop_iface.Get("org.freedesktop.NetworkManager.Device", "State")
+#        name = prop_iface.Get("org.freedesktop.NetworkManager.Device", "Interface")
+#
+#        # and print them out
+#        if state == 8:   # activated
+#            print "Device %s is activated" % name
+#        else:
+#            print "Device %s is not activated" % name
+
+
+# Get active connection state
+    manager_prop_iface = dbus.Interface(proxy, "org.freedesktop.DBus.Properties")
+    active = manager_prop_iface.Get("org.freedesktop.NetworkManager", "ActiveConnections")
+    for a in active:
+        ac_proxy = bus.get_object("org.freedesktop.NetworkManager", a)
+        prop_iface = dbus.Interface(ac_proxy, "org.freedesktop.DBus.Properties")
+        state = prop_iface.Get("org.freedesktop.NetworkManager.ActiveConnection", "State")
+
+        # Connections in NM are a collection of settings that describe everything
+        # needed to connect to a specific network.  Lets get those details so we
+        # can find the user-readable name of the connection.
+        con_path = prop_iface.Get("org.freedesktop.NetworkManager.ActiveConnection", "Connection")
+        con_service = prop_iface.Get("org.freedesktop.NetworkManager.ActiveConnection", "ServiceName")
+
+        # ask the provider of the connection for its details
+        service_proxy = bus.get_object(con_service, con_path)
+        con_iface = dbus.Interface(service_proxy, "org.freedesktop.NetworkManagerSettings.Connection")
+        con_details = con_iface.GetSettings()
+        con_name = con_details['connection']['id']
+
+        if state == 2:   # activated
+            #print "Connection '%s' is activated" % con_name
+            state = True
+        #else:
+            #print "Connection '%s' is activating" % con_name
+    return state
+

=== added file 'GTG/tools/twokeydict.py'
--- GTG/tools/twokeydict.py	1970-01-01 00:00:00 +0000
+++ GTG/tools/twokeydict.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,49 @@
+from GTG.tools.bidict import BiDict
+
+
+class TwoKeyDict(object):
+    '''
+    It's a standard Dictionary with a secondary key.
+    For example, you can add an element ('2', 'II', two'), where the
+    first two arguments are keys and the third is the stored object, and access
+    it as:
+        twokey['2'] ==> 'two'
+        twokey['II'] ==> 'two'
+    You can also request the other key, given one.
+    Function calls start with _ because you'll probably want to rename them when
+    you use this dictionary, for the sake of clarity.
+    '''
+
+
+    def __init__(self, *triplets):
+        super(TwoKeyDict, self).__init__()
+        self._key_to_key_bidict = BiDict()
+        self._primary_to_value = {}
+        for triplet in triplets:
+            self.add(triplet)
+
+    def add(self, triplet):
+        self._key_to_key_bidict.add((triplet[0], triplet[1]))
+        self._primary_to_value[triplet[0]] = triplet[2]
+
+    def _get_by_primary(self, primary):
+        return self._primary_to_value[primary]
+
+    def _get_by_secondary(self, secondary):
+        primary = self._key_to_key_bidict._get_by_second(secondary)
+        return self._get_by_primary(primary)
+
+    def _remove_by_primary(self, primary):
+        del self._primary_to_value[primary]
+        self._key_to_key_bidict._remove_by_first(primary)
+
+    def _remove_by_secondary(self, secondary):
+        primary = self._key_to_key_bidict._get_by_second(secondary)
+        self._remove_by_primary(primary)
+
+    def _get_secondary_key(self, primary):
+        return self._key_to_key_bidict._get_by_first(primary)
+
+    def _get_primary_key(self, secondary):
+        return self._key_to_key_bidict._get_by_second(secondary)
+

=== added file 'GTG/tools/watchdog.py'
--- GTG/tools/watchdog.py	1970-01-01 00:00:00 +0000
+++ GTG/tools/watchdog.py	2010-06-23 01:17:32 +0000
@@ -0,0 +1,26 @@
+import threading
+
+class Watchdog(object):
+    '''
+    a simple thread-safe watchdog.
+    usage:
+    with Watchdod(timeout, error_function):
+        #do something
+    '''
+
+    def __init__(self, timeout, error_function):
+        self.timeout = timeout
+        self.error_function = error_function
+
+    def __enter__(self):
+        self.timer = threading.Timer(self.timeout, self.error_function)
+        self.timer.start()
+
+    def __exit__(self, type, value, traceback):
+        try:
+            self.timer.cancel()
+        except:
+            pass
+        if value == None:
+            return True
+        return False

=== added file 'data/icons/hicolor/scalable/apps/backend_tomboy.png'
Binary files data/icons/hicolor/scalable/apps/backend_tomboy.png	1970-01-01 00:00:00 +0000 and data/icons/hicolor/scalable/apps/backend_tomboy.png	2010-06-23 01:17:32 +0000 differ
=== added file 'data/icons/hicolor/svg/tomboy.svg'
--- data/icons/hicolor/svg/tomboy.svg	1970-01-01 00:00:00 +0000
+++ data/icons/hicolor/svg/tomboy.svg	2010-06-23 01:17:32 +0000
@@ -0,0 +1,463 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/";
+   xmlns:cc="http://creativecommons.org/ns#";
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#";
+   xmlns:svg="http://www.w3.org/2000/svg";
+   xmlns="http://www.w3.org/2000/svg";
+   xmlns:xlink="http://www.w3.org/1999/xlink";
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd";
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape";
+   inkscape:export-ydpi="600"
+   inkscape:export-xdpi="600"
+   inkscape:export-filename="backend_tomboy.png"
+   width="48px"
+   height="48px"
+   id="svg11300"
+   sodipodi:version="0.32"
+   inkscape:version="0.47 r22583"
+   sodipodi:docname="tomboy.svg"
+   inkscape:output_extension="org.inkscape.output.svg.inkscape"
+   sodipodi:modified="true"
+   version="1.1">
+  <defs
+     id="defs3">
+    <inkscape:perspective
+       sodipodi:type="inkscape:persp3d"
+       inkscape:vp_x="0 : 24 : 1"
+       inkscape:vp_y="0 : 1000 : 0"
+       inkscape:vp_z="48 : 24 : 1"
+       inkscape:persp3d-origin="24 : 16 : 1"
+       id="perspective72" />
+    <linearGradient
+       id="linearGradient2994">
+      <stop
+         id="stop2996"
+         offset="0"
+         style="stop-color:#000000;stop-opacity:1;" />
+      <stop
+         id="stop2998"
+         offset="1"
+         style="stop-color:#c9c9c9;stop-opacity:1;" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient2994"
+       id="linearGradient2560"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="translate(-5.825542,0.125)"
+       x1="25.71875"
+       y1="31.046875"
+       x2="25.514589"
+       y2="30.703125" />
+    <linearGradient
+       id="linearGradient2984"
+       inkscape:collect="always">
+      <stop
+         id="stop2986"
+         offset="0"
+         style="stop-color:#e7e2b8;stop-opacity:1;" />
+      <stop
+         id="stop2988"
+         offset="1"
+         style="stop-color:#e7e2b8;stop-opacity:0;" />
+    </linearGradient>
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient2984"
+       id="radialGradient2558"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(2.923565,0,0,2.029717,-61.55532,-27.88417)"
+       cx="29.053354"
+       cy="27.640751"
+       fx="29.053354"
+       fy="27.640751"
+       r="3.2408544" />
+    <linearGradient
+       id="linearGradient2974">
+      <stop
+         id="stop2976"
+         offset="0"
+         style="stop-color:#c1c1c1;stop-opacity:1;" />
+      <stop
+         id="stop2978"
+         offset="1"
+         style="stop-color:#acacac;stop-opacity:1;" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient2974"
+       id="linearGradient2556"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="translate(-5.669292,0)"
+       x1="46"
+       y1="19.8125"
+       x2="47.6875"
+       y2="22.625" />
+    <linearGradient
+       id="linearGradient2966">
+      <stop
+         id="stop2968"
+         offset="0"
+         style="stop-color:#ffd1d1;stop-opacity:1;" />
+      <stop
+         style="stop-color:#ff1d1d;stop-opacity:1;"
+         offset="0.5"
+         id="stop3006" />
+      <stop
+         id="stop2970"
+         offset="1"
+         style="stop-color:#6f0000;stop-opacity:1;" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient2966"
+       id="linearGradient2554"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="translate(-5.669292,0)"
+       x1="48.90625"
+       y1="17.376184"
+       x2="50.988335"
+       y2="22.250591" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient2865"
+       id="radialGradient2552"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1,0,0,0.348243,0,26.35543)"
+       cx="23.5625"
+       cy="40.4375"
+       fx="23.5625"
+       fy="40.4375"
+       r="19.5625" />
+    <linearGradient
+       inkscape:collect="always"
+       id="linearGradient6417">
+      <stop
+         style="stop-color:black;stop-opacity:1;"
+         offset="0"
+         id="stop6419" />
+      <stop
+         style="stop-color:black;stop-opacity:0;"
+         offset="1"
+         id="stop6421" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       id="linearGradient6407">
+      <stop
+         style="stop-color:white;stop-opacity:1;"
+         offset="0"
+         id="stop6409" />
+      <stop
+         style="stop-color:white;stop-opacity:0;"
+         offset="1"
+         id="stop6411" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient6377">
+      <stop
+         style="stop-color:#fff27e;stop-opacity:1;"
+         offset="0"
+         id="stop6379" />
+      <stop
+         style="stop-color:#edd400;stop-opacity:1;"
+         offset="1"
+         id="stop6381" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       id="linearGradient5609">
+      <stop
+         style="stop-color:white;stop-opacity:1;"
+         offset="0"
+         id="stop5611" />
+      <stop
+         style="stop-color:white;stop-opacity:0;"
+         offset="1"
+         id="stop5613" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       id="linearGradient2865">
+      <stop
+         style="stop-color:#000000;stop-opacity:1;"
+         offset="0"
+         id="stop2867" />
+      <stop
+         style="stop-color:#000000;stop-opacity:0;"
+         offset="1"
+         id="stop2869" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient5609"
+       id="linearGradient5615"
+       x1="26.213203"
+       y1="14.08672"
+       x2="26.130388"
+       y2="67.031342"
+       gradientUnits="userSpaceOnUse" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient6377"
+       id="radialGradient6405"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.669712,0,1.702451e-8,1.220484,-30.23773,-11.79928)"
+       cx="45.150326"
+       cy="35.915409"
+       fx="45.150326"
+       fy="35.915409"
+       r="21.626934" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient6407"
+       id="radialGradient6413"
+       cx="43.875"
+       cy="35.90107"
+       fx="43.875"
+       fy="35.90107"
+       r="20.21875"
+       gradientTransform="matrix(10.88255,-6.454846e-8,0,11.39737,-433.5968,-381.3811)"
+       gradientUnits="userSpaceOnUse" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient6417"
+       id="radialGradient6423"
+       cx="39.907337"
+       cy="31.780704"
+       fx="39.907337"
+       fy="31.780704"
+       r="5.2591065"
+       gradientTransform="matrix(1,0,0,0.361345,0,22.29694)"
+       gradientUnits="userSpaceOnUse" />
+  </defs>
+  <sodipodi:namedview
+     stroke="#c4a000"
+     fill="#edd400"
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="0.25490196"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="82.479872"
+     inkscape:cy="36.573128"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     inkscape:grid-bbox="true"
+     inkscape:document-units="px"
+     inkscape:showpageshadow="false"
+     inkscape:window-width="1154"
+     inkscape:window-height="742"
+     inkscape:window-x="124"
+     inkscape:window-y="26"
+     inkscape:window-maximized="0" />
+  <metadata
+     id="metadata4">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage"; />
+        <dc:creator>
+          <cc:Agent>
+            <dc:title>Jakub Steiner</dc:title>
+          </cc:Agent>
+        </dc:creator>
+        <dc:source>http://jimmac.musichall.cz</dc:source>
+        <cc:license
+           rdf:resource="http://creativecommons.org/licenses/GPL/2.0/"; />
+        <dc:title>Tomboy</dc:title>
+      </cc:Work>
+      <cc:License
+         rdf:about="http://creativecommons.org/licenses/GPL/2.0/";>
+        <cc:permits
+           rdf:resource="http://web.resource.org/cc/Reproduction"; />
+        <cc:permits
+           rdf:resource="http://web.resource.org/cc/Distribution"; />
+        <cc:requires
+           rdf:resource="http://web.resource.org/cc/Notice"; />
+        <cc:permits
+           rdf:resource="http://web.resource.org/cc/DerivativeWorks"; />
+        <cc:requires
+           rdf:resource="http://web.resource.org/cc/ShareAlike"; />
+        <cc:requires
+           rdf:resource="http://web.resource.org/cc/SourceCode"; />
+      </cc:License>
+    </rdf:RDF>
+  </metadata>
+  <g
+     id="layer1"
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer">
+    <g
+       style="display:inline"
+       transform="matrix(0.211321,0,0,0.209762,-0.326773,3.523521)"
+       id="g1197">
+      <path
+         d="M 32.706693,164.36026 C 22.319193,164.36026 13.956693,172.72276 13.956693,183.11026 C 13.956693,193.49776 22.319193,201.86026 32.706693,201.86026 L 205.20669,201.86026 C 215.59419,201.86026 223.95669,193.49776 223.95669,183.11026 C 223.95669,172.72276 215.59419,164.36026 205.20669,164.36026 L 32.706693,164.36026 z "
+         style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
+         id="path1196" />
+      <path
+         d="M 32.706693,165.61026 C 23.011693,165.61026 15.206693,173.41526 15.206693,183.11026 C 15.206693,192.80526 23.011693,200.61026 32.706693,200.61026 L 205.20669,200.61026 C 214.90169,200.61026 222.70669,192.80526 222.70669,183.11026 C 222.70669,173.41526 214.90169,165.61026 205.20669,165.61026 L 32.706693,165.61026 z "
+         style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
+         id="path1195" />
+      <path
+         d="M 32.706694,166.86026 C 23.704194,166.86026 16.456694,174.10776 16.456694,183.11026 C 16.456694,192.11276 23.704194,199.36026 32.706694,199.36026 L 205.20669,199.36026 C 214.20919,199.36026 221.45669,192.11276 221.45669,183.11026 C 221.45669,174.10776 214.20919,166.86026 205.20669,166.86026 L 32.706694,166.86026 z "
+         style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
+         id="path1194" />
+      <path
+         d="M 32.706694,168.11026 C 24.396694,168.11026 17.706694,174.80026 17.706694,183.11026 C 17.706694,191.42026 24.396694,198.11026 32.706694,198.11026 L 205.20669,198.11026 C 213.51669,198.11026 220.20669,191.42026 220.20669,183.11026 C 220.20669,174.80026 213.51669,168.11026 205.20669,168.11026 L 32.706694,168.11026 z "
+         style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
+         id="path1193" />
+      <path
+         d="M 32.707764,169.36026 C 25.090264,169.36026 18.957764,175.49276 18.957764,183.11026 C 18.957764,190.72776 25.090264,196.86026 32.707764,196.86026 L 205.20618,196.86026 C 212.82368,196.86026 218.95618,190.72776 218.95618,183.11026 C 218.95618,175.49276 212.82368,169.36026 205.20618,169.36026 L 32.707764,169.36026 z "
+         style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
+         id="path1192" />
+      <path
+         d="M 32.706694,170.61026 C 25.781694,170.61026 20.206694,176.18526 20.206694,183.11026 C 20.206694,190.03526 25.781694,195.61026 32.706694,195.61026 L 205.20669,195.61026 C 212.13169,195.61026 217.70669,190.03526 217.70669,183.11026 C 217.70669,176.18526 212.13169,170.61026 205.20669,170.61026 L 32.706694,170.61026 z "
+         style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
+         id="path1191" />
+      <path
+         d="M 32.706694,171.86026 C 26.474194,171.86026 21.456694,176.87776 21.456694,183.11026 C 21.456694,189.34276 26.474194,194.36026 32.706694,194.36026 L 205.20669,194.36026 C 211.43919,194.36026 216.45669,189.34276 216.45669,183.11026 C 216.45669,176.87776 211.43919,171.86026 205.20669,171.86026 L 32.706694,171.86026 z "
+         style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
+         id="path1190" />
+      <path
+         d="M 32.706694,173.11026 C 27.166694,173.11026 22.706694,177.57026 22.706694,183.11026 C 22.706694,188.65026 27.166694,193.11026 32.706694,193.11026 L 205.20669,193.11026 C 210.74669,193.11026 215.20669,188.65026 215.20669,183.11026 C 215.20669,177.57026 210.74669,173.11026 205.20669,173.11026 L 32.706694,173.11026 z "
+         style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt"
+         id="path1189" />
+    </g>
+    <path
+       style="color:black;fill:#edd400;fill-opacity:1;fill-rule:evenodd;stroke:#c4a000;stroke-width:0.99999982;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+       d="M 10.301452,14.596007 L 38.950705,14.94956 C 39.713282,14.94956 40.194615,15.526206 40.327198,16.143397 C 40.327198,16.143397 44.728528,35.958541 44.728528,35.958541 C 44.728528,35.958541 44.740986,42.306163 44.740986,42.306163 C 44.740986,42.967549 44.127071,43.5 43.364494,43.5 L 5.8876636,43.5 C 5.1250868,43.5 4.5111713,42.967549 4.5111713,42.306163 L 4.4999999,36.139247 L 8.9249601,15.789844 C 9.2343193,15.128458 9.5388757,14.596007 10.301452,14.596007 z "
+       id="rect1975"
+       sodipodi:nodetypes="ccccccccccc" />
+    <rect
+       style="opacity:0.37078654;color:black;fill:#f57900;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+       id="rect2851"
+       width="39.048077"
+       height="7.0714951"
+       x="5.1146202"
+       y="35.957905"
+       rx="0.67937863"
+       ry="0.67937863" />
+    <path
+       style="opacity:0.16292138;color:black;fill:white;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+       d="M 5.0643333,36.53243 C 5.0643333,36.53243 5.2151951,36.0021 5.7683553,35.957906 L 43.332958,35.957906 C 44.087267,35.957906 44.137554,36.709207 44.137554,36.709207 C 44.137554,36.709207 44.161153,35.089634 42.853683,35.089634 L 6.4417985,35.089634 C 5.4360528,35.178022 5.0643333,35.869517 5.0643333,36.53243 z "
+       id="path2853"
+       sodipodi:nodetypes="ccccccc" />
+    <path
+       style="opacity:0.4831461;color:black;fill:none;fill-opacity:1;fill-rule:evenodd;stroke:url(#linearGradient5615);stroke-width:0.99999982;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;visibility:visible;display:inline;overflow:visible"
+       d="M 10.3125,15.59375 C 10.161184,15.59375 10.21304,15.567007 10.15625,15.625 C 10.105373,15.676955 10.000439,15.900085 9.875,16.15625 C 9.8604211,16.186022 9.8588161,16.18654 9.84375,16.21875 L 5.5,36.125 L 5.5,36.1875 L 5.5,42.3125 C 5.5,42.356573 5.5723676,42.5 5.875,42.5 L 43.375,42.5 C 43.677632,42.5 43.75,42.356571 43.75,42.3125 C 43.75,42.3125 43.719646,36.244353 43.71875,36.0625 C 43.718499,36.061373 43.719177,35.970668 43.71875,35.96875 C 43.625157,35.547912 39.34375,16.375 39.34375,16.375 C 39.2872,16.111751 39.174175,15.9375 38.9375,15.9375 L 10.3125,15.59375 z "
+       id="path4730" />
+    <path
+       style="opacity:0.46629214;color:black;fill:url(#radialGradient6423);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+       d="M 44.194174,35.681088 C 43.814854,34.425759 43.31029,31.880389 43.31029,31.880389 L 34.383068,35.06237 C 40.128311,35.06237 42.956737,34.797205 44.194174,35.681088 z "
+       id="path6415"
+       sodipodi:nodetypes="cccc" />
+    <path
+       sodipodi:nodetypes="cczczcccc"
+       id="path2524"
+       d="M 8.7832195,16.426565 L 4.6483895,34.455844 C 4.6483895,34.455844 26.119997,34.580845 33.923967,34.580845 C 41.904536,34.580845 45.079034,31.612425 45.079034,31.612425 C 45.079034,31.612425 44.072291,31.158937 42.753925,26.645704 C 42.753925,26.645704 40.35014,16.145315 40.35014,16.145315 C 39.886699,14.958914 39.493934,14.481646 38.59375,14.489065 L 10.8125,14.489065 C 9.0672254,14.521152 9.0760813,15.324617 8.7832195,16.426565 z "
+       style="fill:url(#radialGradient6405);fill-opacity:1;fill-rule:evenodd;stroke:#c4a000;stroke-width:0.99999964px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+    <path
+       style="opacity:0.46629214;fill:none;fill-opacity:1;fill-rule:evenodd;stroke:url(#radialGradient6413);stroke-width:0.99999964px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       d="M 10.8125,15.5 C 10.172211,15.51823 10.104203,15.599785 10.0625,15.65625 C 10.019129,15.714974 9.9124269,16.076338 9.75,16.6875 L 5.90625,33.46875 C 7.6006857,33.478529 26.476894,33.59375 33.9375,33.59375 C 37.803786,33.59375 40.454778,32.879448 42.125,32.1875 C 43.179074,31.750813 43.238325,31.610471 43.5625,31.375 C 43.071872,30.585095 42.457459,29.252403 41.78125,26.9375 C 41.780599,26.916672 41.780599,26.895828 41.78125,26.875 C 41.78125,26.875 39.475321,16.832651 39.40625,16.53125 C 39.192263,15.983448 39.024353,15.680487 38.9375,15.59375 C 38.850647,15.507013 38.865951,15.497757 38.59375,15.5 L 10.84375,15.5 L 10.8125,15.5 z "
+       id="path6403" />
+    <path
+       sodipodi:nodetypes="ccccccc"
+       id="path6359"
+       d="M 5.0643333,38.53243 C 5.0643333,38.53243 5.2151951,38.0021 5.7683553,37.957906 L 43.332958,37.957906 C 44.087267,37.957906 44.137554,38.709207 44.137554,38.709207 C 44.137554,38.709207 44.161153,37.089634 42.853683,37.089634 L 6.4417985,37.089634 C 5.4360528,37.178022 5.0643333,37.869517 5.0643333,38.53243 z "
+       style="opacity:0.26404497;color:black;fill:white;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible" />
+    <path
+       style="opacity:0.26404497;color:black;fill:white;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+       d="M 5.0643333,40.53243 C 5.0643333,40.53243 5.2151951,40.0021 5.7683553,39.957906 L 43.332958,39.957906 C 44.087267,39.957906 44.137554,40.709207 44.137554,40.709207 C 44.137554,40.709207 44.161153,39.089634 42.853683,39.089634 L 6.4417985,39.089634 C 5.4360528,39.178022 5.0643333,39.869517 5.0643333,40.53243 z "
+       id="path6361"
+       sodipodi:nodetypes="ccccccc" />
+    <path
+       sodipodi:nodetypes="ccccccc"
+       id="path6363"
+       d="M 5.0643333,42.53243 C 5.0643333,42.53243 5.2151951,42.0021 5.7683553,41.957906 L 43.332958,41.957906 C 44.087267,41.957906 44.137554,42.709207 44.137554,42.709207 C 44.137554,42.709207 44.161153,41.089634 42.853683,41.089634 L 6.4417985,41.089634 C 5.4360528,41.178022 5.0643333,41.869517 5.0643333,42.53243 z "
+       style="opacity:0.26404497;color:black;fill:white;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible" />
+    <path
+       inkscape:r_cy="true"
+       inkscape:r_cx="true"
+       transform="matrix(0.616613,0,0,0.293577,11.48816,14.62848)"
+       d="M 43.125 40.4375 A 19.5625 6.8125 0 1 1  4,40.4375 A 19.5625 6.8125 0 1 1  43.125 40.4375 z"
+       sodipodi:ry="6.8125"
+       sodipodi:rx="19.5625"
+       sodipodi:cy="40.4375"
+       sodipodi:cx="23.5625"
+       id="path3008"
+       style="opacity:0.2;color:black;fill:url(#radialGradient2552);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+       sodipodi:type="arc" />
+    <g
+       style="display:inline"
+       id="g1574"
+       transform="matrix(1.033699,-0.276979,0.276979,1.033699,14.81828,-29.04823)"
+       inkscape:r_cx="true"
+       inkscape:r_cy="true">
+      <path
+         transform="translate(-29.75546,19)"
+         sodipodi:nodetypes="cccccc"
+         id="path2960"
+         d="M 17.34116,32.5 L 22.96616,26.875 L 43.059909,17.125 C 46.309909,15.875 48.247409,20.5 45.372409,22.125 L 25.34116,31.5 L 17.34116,32.5 z "
+         style="color:black;fill:#cb9022;fill-opacity:1;fill-rule:evenodd;stroke:#5c410c;stroke-width:0.93443578;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+         inkscape:r_cx="true"
+         inkscape:r_cy="true" />
+      <path
+         transform="translate(-29.75546,19)"
+         style="color:black;fill:url(#linearGradient2554);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+         d="M 38.330708,20 C 38.330708,20 39.768208,20.09375 40.330708,21.34375 C 40.910201,22.631511 40.330708,24 40.330708,24 L 45.361958,21.53125 C 45.361958,21.53125 46.81399,20.649883 46.018208,18.6875 C 45.233296,16.751923 43.330708,17.53125 43.330708,17.53125 L 38.330708,20 z "
+         id="path2964"
+         sodipodi:nodetypes="czcczcc"
+         inkscape:r_cx="true"
+         inkscape:r_cy="true" />
+      <path
+         transform="translate(-29.75546,19)"
+         sodipodi:nodetypes="czcczcc"
+         id="path2962"
+         d="M 38.330708,20 C 38.330708,20 39.768208,20.09375 40.330708,21.34375 C 40.910201,22.631511 40.330708,24 40.330708,24 L 42.330708,23 C 42.330708,23 43.15774,21.681133 42.549458,20.3125 C 41.924458,18.90625 40.330708,19 40.330708,19 L 38.330708,20 z "
+         style="color:black;fill:url(#linearGradient2556);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+         inkscape:r_cx="true"
+         inkscape:r_cy="true" />
+      <path
+         transform="translate(-29.75546,19)"
+         sodipodi:nodetypes="cccc"
+         id="path2982"
+         d="M 18.768208,31.78125 L 23.268208,27.28125 C 24.768208,28.09375 25.549458,29.4375 25.143208,31 L 18.768208,31.78125 z "
+         style="color:black;fill:url(#radialGradient2558);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+         inkscape:r_cx="true"
+         inkscape:r_cy="true" />
+      <path
+         transform="translate(-29.75546,19)"
+         sodipodi:nodetypes="cccc"
+         id="path2992"
+         d="M 20.111958,30.375 L 18.486958,31.96875 L 20.830708,31.65625 C 21.049458,30.9375 20.643208,30.59375 20.111958,30.375 z "
+         style="color:black;fill:url(#linearGradient2560);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+         inkscape:r_cx="true"
+         inkscape:r_cy="true" />
+      <path
+         transform="translate(-29.75546,19)"
+         sodipodi:nodetypes="ccccc"
+         id="path3002"
+         d="M 23.268208,27.25 L 24.830708,28.5 L 40.218048,21.18133 C 39.773616,20.325286 38.976281,20.096733 38.314669,20.019068 L 23.268208,27.25 z "
+         style="color:black;fill:white;fill-opacity:0.36363639;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+         inkscape:r_cx="true"
+         inkscape:r_cy="true" />
+      <path
+         transform="translate(-29.75546,19)"
+         sodipodi:nodetypes="ccccc"
+         id="path3004"
+         d="M 25.143208,31.0625 L 25.330708,30.3125 L 40.561798,23.1829 C 40.561798,23.1829 40.451638,23.796527 40.345919,23.93225 L 25.143208,31.0625 z "
+         style="color:black;fill:black;fill-opacity:0.36363639;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;marker:none;marker-start:none;marker-mid:none;marker-end:none;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;visibility:visible;display:inline;overflow:visible"
+         inkscape:r_cx="true"
+         inkscape:r_cy="true" />
+    </g>
+  </g>
+</svg>


Follow ups