← 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/32715
Your team Gtg developers is requested to review the proposed merge of lp:~gtg-user/gtg/tomboy-backend into lp:gtg.
=== modified file 'CHANGELOG'
--- CHANGELOG	2010-08-04 00:30:22 +0000
+++ CHANGELOG	2010-08-15 19:35:57 +0000
@@ -4,6 +4,7 @@
     * Fixed bug with data consistency #579189, by Marko Kevac
     * Added samba bugzilla to the bugzilla plugin, by Jelmer Vernoij
     * Fixed bug #532392, a start date is later than a due date, by Volodymyr Floreskul
+    * New Tomboy/Gnote backend, by Luca Invernizzi
 
 2010-03-01 Getting Things GNOME! 0.2.2
     * Autostart on login, by Luca Invernizzi

=== added file 'GTG/backends/backend_gnote.py'
--- GTG/backends/backend_gnote.py	1970-01-01 00:00:00 +0000
+++ GTG/backends/backend_gnote.py	2010-08-15 19:35:57 +0000
@@ -0,0 +1,61 @@
+# -*- 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/>.
+# -----------------------------------------------------------------------------
+
+'''
+The gnote backend. The actual backend is all in GenericTomboy, since it's
+shared with the tomboy backend.
+'''
+#To introspect tomboy: qdbus org.gnome.Tomboy /org/gnome/Tomboy/RemoteControl
+
+from GTG.backends.genericbackend import GenericBackend
+from GTG                         import _
+from GTG.backends.generictomboy  import GenericTomboy
+
+
+
+class Backend(GenericTomboy):
+    '''
+    A simple class that adds some description to the GenericTomboy class.
+    It's done this way since Tomboy and Gnote backends have different
+    descriptions and Dbus addresses but the same backend behind them.
+    '''
+    
+
+    _general_description = { \
+        GenericBackend.BACKEND_NAME:       "backend_gnote", \
+        GenericBackend.BACKEND_HUMAN_NAME: _("Gnote"), \
+        GenericBackend.BACKEND_AUTHORS:    ["Luca Invernizzi"], \
+        GenericBackend.BACKEND_TYPE:       GenericBackend.TYPE_READWRITE, \
+        GenericBackend.BACKEND_DESCRIPTION: \
+            _("This backend can synchronize all or part of your 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-Gnote"]}, \
+        }
+
+    _BUS_ADDRESS = ("org.gnome.Gnote",
+                     "/org/gnome/Gnote/RemoteControl",
+                     "org.gnome.Gnote.RemoteControl")

=== 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-08-15 19:35:57 +0000
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Getting 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/>.
+# -----------------------------------------------------------------------------
+
+'''
+The tomboy backend. The actual backend is all in GenericTomboy, since it's
+shared with the Gnote backend.
+'''
+
+from GTG.backends.genericbackend import GenericBackend
+from GTG                         import _
+from GTG.backends.generictomboy  import GenericTomboy
+
+
+
+class Backend(GenericTomboy):
+    '''
+    A simple class that adds some description to the GenericTomboy class.
+    It's done this way since Tomboy and Gnote backends have different
+    descriptions and Dbus addresses but the same backend behind them.
+    '''
+    
+
+    _general_description = { \
+        GenericBackend.BACKEND_NAME:       "backend_tomboy", \
+        GenericBackend.BACKEND_HUMAN_NAME: _("Tomboy"), \
+        GenericBackend.BACKEND_AUTHORS:    ["Luca Invernizzi"], \
+        GenericBackend.BACKEND_TYPE:       GenericBackend.TYPE_READWRITE, \
+        GenericBackend.BACKEND_DESCRIPTION: \
+            _("This backend can synchronize all or part of your Tomboy"
+              " 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"]}, \
+        }
+
+    _BUS_ADDRESS = ("org.gnome.Tomboy",
+                     "/org/gnome/Tomboy/RemoteControl",
+                     "org.gnome.Tomboy.RemoteControl")

=== added file 'GTG/backends/generictomboy.py'
--- GTG/backends/generictomboy.py	1970-01-01 00:00:00 +0000
+++ GTG/backends/generictomboy.py	2010-08-15 19:35:57 +0000
@@ -0,0 +1,592 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Getting 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/>.
+# -----------------------------------------------------------------------------
+
+'''
+Contains the Backend class for both Tomboy and Gnote
+'''
+#Note: To introspect tomboy, execute:
+#    qdbus org.gnome.Tomboy /org/gnome/Tomboy/RemoteControl
+
+import os
+import re
+import threading
+import uuid
+import dbus
+import datetime
+import unicodedata
+
+from GTG.tools.testingmode       import TestingMode
+from GTG.tools.borg              import Borg
+from GTG.backends.genericbackend import GenericBackend
+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
+from GTG.tools.interruptible     import interruptible
+
+
+
+class GenericTomboy(GenericBackend):
+    '''Backend class for Tomboy/Gnote'''
+    
+
+###############################################################################
+### Backend standard methods ##################################################
+###############################################################################
+
+    def __init__(self, parameters):
+        """
+        See GenericBackend for an explanation of this function.
+        """
+        super(GenericTomboy, self).__init__(parameters)
+        #loading the saved state of the synchronization, if any
+        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 the backend is being tested, we connect to a different DBus
+        # interface to avoid clashing with a running instance of Tomboy
+        if TestingMode().get_testing_mode():
+            #just used for testing purposes
+            self.BUS_ADDRESS = \
+                    self._parameters["use this fake connection instead"]
+        else:
+            self.BUS_ADDRESS = self._BUS_ADDRESS
+        #we let some time pass before considering a tomboy task for importing,
+        # as the user may still be editing it. Here, we store the Timer objects
+        # that will execute after some time after each tomboy signal.
+        #NOTE: I'm not sure if this is the case anymore (but it shouldn't hurt
+        #      anyway). (invernizzi)
+        self._tomboy_setting_timers = {}
+        
+    def initialize(self):
+        '''
+        See GenericBackend for an explanation of this function.
+        Connects to the session bus and sets the callbacks for bus signals
+        '''
+        super(GenericTomboy, 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")
+
+    @interruptible
+    def start_get_tasks(self):
+        '''
+        See GenericBackend for an explanation of this function.
+        Gets all the notes from Tomboy and sees if they must be added in GTG
+        (and, if so, it adds them).
+        '''
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                tomboy_notes = [note_id for note_id in \
+                                tomboy.ListAllNotes()]
+        #adding the new ones
+        for note in tomboy_notes:
+            self.cancellation_point()
+            self._process_tomboy_note(note)
+        #checking if some notes have been deleted while GTG was not running
+        stored_notes_ids = self.sync_engine.get_all_remote()
+        for note in set(stored_notes_ids).difference(set(tomboy_notes)):
+            self.on_note_deleted(note, None)
+
+    def save_state(self):
+        '''Saves the state of the synchronization'''
+        self._store_pickled_file(self.data_path, self.sync_engine)
+
+    def quit(self, disable = False):
+        '''
+        See GenericBackend for an explanation of this function.
+        '''
+        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(GenericTomboy, self).quit(disable)
+
+###############################################################################
+### Something got removed #####################################################
+###############################################################################
+
+    @interruptible
+    def on_note_deleted(self, note, something):
+        '''
+        Callback, executed when a tomboy note is deleted.
+        Deletes the related GTG task.
+
+        @param note: the id of the Tomboy note
+        @param something: not used, here for signal callback compatibility
+        '''
+        with self.datastore.get_backend_mutex():
+            self.cancellation_point()
+            try:
+                tid = self.sync_engine.get_local_id(note)
+            except KeyError:
+                return
+            if self.datastore.has_task(tid):
+                self.datastore.request_task_deletion(tid)
+                self.break_relationship(remote_id = note)
+
+    @interruptible
+    def remove_task(self, tid):
+        '''
+        See GenericBackend for an explanation of this function.
+        '''
+        with self.datastore.get_backend_mutex():
+            self.cancellation_point()
+            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)
+                        self.break_relationship(local_id = tid)
+
+    def _exec_lost_syncability(self, tid, note):
+        '''
+        Executed when a relationship between tasks loses its syncability
+        property. See SyncEngine for an explanation of that.
+        This function finds out which object (task/note) is the original one
+        and which is the copy, and deletes the copy.
+
+        @param tid: a GTG task tid
+        #param note: a tomboy note id
+        '''
+        self.cancellation_point()
+        meme = self.sync_engine.get_meme_from_remote_id(note)
+        #First of all, the relationship is lost
+        self.sync_engine.break_relationship(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)
+
+###############################################################################
+### Process tasks #############################################################
+###############################################################################
+
+    def _process_tomboy_note(self, note):
+        '''
+        Given a tomboy note, finds out if it must be synced to a GTG note and, 
+        if so, it carries out the synchronization (by creating or updating a GTG
+        task, or deleting itself if the related task has been deleted)
+
+        @param note: a Tomboy note id
+        '''
+        with self.datastore.get_backend_mutex():
+            self.cancellation_point()
+            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, %s)" % (action, is_syncable))
+
+            if action == SyncEngine.ADD:
+                tid = str(uuid.uuid4())
+                task = self.datastore.task_factory(tid)
+                self._populate_task(task, note)
+                self.record_relationship(local_id = tid,\
+                            remote_id = note, \
+                            meme = SyncMeme(task.get_modified(),
+                                            self.get_modified_for_note(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)
+                    meme.set_local_last_modified(task.get_modified())
+                    meme.set_remote_last_modified(\
+                                        self.get_modified_for_note(note))
+                    self.save_state()
+
+            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)
+
+    @interruptible
+    def set_task(self, task):
+        '''
+        See GenericBackend for an explanation of this function.
+        '''
+        self.cancellation_point()
+        is_syncable = self._gtg_task_is_syncable_per_attached_tags(task)
+        tid = task.get_id()
+        with self.datastore.get_backend_mutex():
+            with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+                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._populate_note(note, task)
+                    self.record_relationship( \
+                        local_id = tid, remote_id = note, \
+                        meme = SyncMeme(task.get_modified(),
+                                        self.get_modified_for_note(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)
+                        meme.set_local_last_modified(task.get_modified())
+                        meme.set_remote_last_modified(\
+                                            self.get_modified_for_note(note))
+                        self.save_state()
+
+                elif action == SyncEngine.REMOVE:
+                    self.datastore.request_task_deletion(tid)
+                    try:
+                        self.sync_engine.break_relationship(local_id = tid)
+                        self.save_state()
+                    except KeyError:
+                        pass
+
+                elif action == SyncEngine.LOST_SYNCABILITY:
+                    self._exec_lost_syncability(tid, note)
+
+###############################################################################
+### Helper methods ############################################################
+###############################################################################
+
+    @interruptible
+    def on_note_saved(self,  note):
+        '''
+        Callback, executed when a tomboy note is saved by Tomboy itself.
+        Updates the related GTG task (or creates one, if necessary).
+
+        @param note: the id of the Tomboy note
+        '''
+        self.cancellation_point()
+        #NOTE: we let some seconds pass before executing the real callback, as
+        #      the editing of the Tomboy note may still be in progress
+        @interruptible
+        def _execute_on_note_saved(self, note):
+            self.cancellation_point()
+            try:
+                del self._tomboy_setting_timers[note]
+            except:
+                pass
+            self._process_tomboy_note(note)
+            self.save_state()
+
+        try:
+            self._tomboy_setting_timers[note].cancel()
+        except KeyError:
+            pass
+        finally:
+            timer =threading.Timer(5, _execute_on_note_saved,
+                                   args = (self, note))
+            self._tomboy_setting_timers[note] = timer
+            timer.start()
+
+    def _tomboy_note_is_syncable(self, note):
+        '''
+        Returns True if this tomboy note should be synced into GTG tasks.
+
+        @param note: the note id
+        @returns Boolean
+        '''
+        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 _tomboy_note_exists(self, note):
+        '''
+        Returns True if  a tomboy note exists with the given id.
+
+        @param note: the note id
+        @returns Boolean
+        '''
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                return tomboy.NoteExists(note)
+
+    def get_modified_for_note(self, note):
+        '''
+        Returns the modification time for the given note id.
+
+        @param note: the note id
+        @returns datetime.datetime
+        '''
+        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):
+        '''
+        Tomboy does not have a "getTitle" and "getText" functions to get the
+        title and the text of a note separately. Instead, it has a getContent
+        function, that returns both of them.
+        This function splits up the output of getContent into a title string and
+        a text string.
+
+        @param content: a string, the result of a getContent call
+        @returns list: a list composed by [title, text]
+        '''
+        try:
+            end_of_title = content.index('\n')
+        except ValueError:
+            return content, unicode("")
+        title = content[: end_of_title]
+        if len(content) > end_of_title:
+            return title, content[end_of_title +1 :]
+        else:
+            return title, unicode("")
+
+    def _populate_task(self, task, note):
+        '''
+        Copies the content of a Tomboy note into a task.
+
+        @param task: a GTG Task
+        @param note: a Tomboy 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(unicode(content))
+        #Tomboy speaks unicode, we don't
+        title = unicodedata.normalize('NFKD', title).encode('ascii', 'ignore')
+        text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore')
+        task.set_title(title)
+        task.set_text(text)
+        task.add_remote_id(self.get_id(), note)
+
+    def _populate_note(self, note, task):
+        '''
+        Copies the content of a task into a Tomboy note.
+
+        @param note: a Tomboy note
+        @param task: a GTG Task
+        '''
+        title = task.get_title()
+        tested_title = title
+        duplicate_counter = 1
+        with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
+            with self.DbusWatchdog(self):
+                tomboy.SetNoteContents(note, title + '\n' + \
+                                       task.get_excerpt(strip_tags = False))
+
+    def break_relationship(self, *args, **kwargs):
+        '''
+        Proxy method for SyncEngine.break_relationship, which also saves the
+        state of the synchronization.
+        '''
+        #tomboy passes Dbus.String objects, which are not pickable. We convert
+        # those to unicode
+        kwargs["remote_id"] = unicode(kwargs["remote_id"])
+        try:
+            self.sync_engine.break_relationship(*args, **kwargs)
+            #we try to save the state at each change in the sync_engine:
+            #it's slower, but it should avoid widespread task
+            #duplication
+            self.save_state()
+        except KeyError:
+            pass
+
+    def record_relationship(self, *args, **kwargs):
+        '''
+        Proxy method for SyncEngine.break_relationship, which also saves the
+        state of the synchronization.
+        '''
+        #tomboy passes Dbus.String objects, which are not pickable. We convert
+        # those to unicode
+        kwargs["remote_id"] = unicode(kwargs["remote_id"])
+
+        self.sync_engine.record_relationship(*args, **kwargs)
+        #we try to save the state at each change in the sync_engine:
+        #it's slower, but it should avoid widespread task
+        #duplication
+        self.save_state()
+
+###############################################################################
+### 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):
+            '''
+            Sees if a TomboyConnection object already exists. If so, since we
+            are inheriting from a Borg object, the initialization already took
+            place.
+            If not, it tries to connect to Tomboy via Dbus. If the connection
+            is not possible, the user is notified about it.
+
+            @param backend: a reference to a Backend
+            @param bus_name: the DBUS address of Tomboy
+            @param bus_path: the DBUS path of Tomboy RemoteControl
+            @param bus_interface: the DBUS address of Tomboy RemoteControl 
+            '''
+            super(GenericTomboy.TomboyConnection, self).__init__()
+            if hasattr(self, "tomboy_connection_is_ok") and \
+                                self.tomboy_connection_is_ok:
+                return
+            self.backend = backend
+            with GenericTomboy.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):
+            '''
+            Returns the Tomboy connection
+
+            @returns dbus.Interface
+            '''
+            return self.tomboy
+
+        def __exit__(self, exception_type, value, traceback):
+            '''
+            Checks the state of the connection.
+            If something went wrong for the connection, notifies the user.
+
+            @param exception_type: the type of exception that occurred, or
+                                   None
+            @param value: the instance of the exception occurred, or None
+            @param traceback: the traceback of the error
+            @returns: False if some exception must be re-raised.
+            '''
+            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):
+            '''
+            Simple constructor, which sets _when_taking_too_long as the function
+            to run when the connection is taking too long.
+
+            @param backend: a Backend object
+            '''
+            self.backend = backend
+            super(GenericTomboy.DbusWatchdog, self).__init__(3, \
+                                    self._when_taking_too_long)
+
+        def _when_taking_too_long(self):
+            '''
+            Function that is executed when the Dbus connection seems to be
+            hanging. It disables the backend and signals the error to the user.
+            '''
+            Log.error("Dbus connection is taking too long for the Tomboy/Gnote"
+                      "backend!")
+            self.backend.quit(disable = True)
+            BackendSignals().backend_failed(self.backend.get_id(), \
+                            BackendSignals.ERRNO_DBUS)
+

=== 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-08-15 19:35:57 +0000
@@ -0,0 +1,397 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Getting 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
+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
+
+
+
+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) 
+        print "QUIT"
+        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():
+    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 'data/icons/hicolor/scalable/apps/backend_gnote.png'
Binary files data/icons/hicolor/scalable/apps/backend_gnote.png	1970-01-01 00:00:00 +0000 and data/icons/hicolor/scalable/apps/backend_gnote.png	2010-08-15 19:35:57 +0000 differ
=== 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-08-15 19:35:57 +0000 differ

Follow ups