GTG

Merge lp:~gtg-user/gtg/tomboy-backend into lp:~gtg/gtg/old-trunk

Proposed by Luca Invernizzi
Status: Merged
Merged at revision: 880
Proposed branch: lp:~gtg-user/gtg/tomboy-backend
Merge into: lp:~gtg/gtg/old-trunk
Prerequisite: lp:~gtg-user/gtg/multibackends-halfgsoc_merge
Diff against target: 1137 lines (+1102/-0)
5 files modified
CHANGELOG (+1/-0)
GTG/backends/backend_gnote.py (+61/-0)
GTG/backends/backend_tomboy.py (+60/-0)
GTG/backends/generictomboy.py (+583/-0)
GTG/tests/test_backend_tomboy.py (+397/-0)
To merge this branch: bzr merge lp:~gtg-user/gtg/tomboy-backend
Reviewer Review Type Date Requested Status
Gtg developers Pending
Lionel Dricot Pending
Review via email: mp+32715@code.launchpad.net

This proposal supersedes a proposal from 2010-08-13.

Description of the change

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-.

To post a comment you must log in.
Revision history for this message
Lionel Dricot (ploum-deactivatedaccount) wrote : Posted in a previous version of this proposal

Not touching anything in GTG so, obviously, you can merge it. Very good work !

review: Approve
Revision history for this message
Luca Invernizzi (invernizzi) wrote :

Added testcase for tomboy(forgot to add this). Needs the updated backends framework (merge request submitted) to pass the test.

lp:~gtg-user/gtg/tomboy-backend updated
884. By Luca Invernizzi

cherrypicking from my development branch

885. By Luca Invernizzi

merge w/ trunk

886. By Luca Invernizzi

small typo in docs:

887. By Luca Invernizzi

merge w/ trunk

888. By Luca Invernizzi

cherrypicking from my development branch

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'CHANGELOG'
2--- CHANGELOG 2010-08-04 00:30:22 +0000
3+++ CHANGELOG 2010-08-26 23:33:46 +0000
4@@ -4,6 +4,7 @@
5 * Fixed bug with data consistency #579189, by Marko Kevac
6 * Added samba bugzilla to the bugzilla plugin, by Jelmer Vernoij
7 * Fixed bug #532392, a start date is later than a due date, by Volodymyr Floreskul
8+ * New Tomboy/Gnote backend, by Luca Invernizzi
9
10 2010-03-01 Getting Things GNOME! 0.2.2
11 * Autostart on login, by Luca Invernizzi
12
13=== added file 'GTG/backends/backend_gnote.py'
14--- GTG/backends/backend_gnote.py 1970-01-01 00:00:00 +0000
15+++ GTG/backends/backend_gnote.py 2010-08-26 23:33:46 +0000
16@@ -0,0 +1,61 @@
17+# -*- coding: utf-8 -*-
18+# -----------------------------------------------------------------------------
19+# Gettings Things Gnome! - a personal organizer for the GNOME desktop
20+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
21+#
22+# This program is free software: you can redistribute it and/or modify it under
23+# the terms of the GNU General Public License as published by the Free Software
24+# Foundation, either version 3 of the License, or (at your option) any later
25+# version.
26+#
27+# This program is distributed in the hope that it will be useful, but WITHOUT
28+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
29+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
30+# details.
31+#
32+# You should have received a copy of the GNU General Public License along with
33+# this program. If not, see <http://www.gnu.org/licenses/>.
34+# -----------------------------------------------------------------------------
35+
36+'''
37+The gnote backend. The actual backend is all in GenericTomboy, since it's
38+shared with the tomboy backend.
39+'''
40+#To introspect tomboy: qdbus org.gnome.Tomboy /org/gnome/Tomboy/RemoteControl
41+
42+from GTG.backends.genericbackend import GenericBackend
43+from GTG import _
44+from GTG.backends.generictomboy import GenericTomboy
45+
46+
47+
48+class Backend(GenericTomboy):
49+ '''
50+ A simple class that adds some description to the GenericTomboy class.
51+ It's done this way since Tomboy and Gnote backends have different
52+ descriptions and Dbus addresses but the same backend behind them.
53+ '''
54+
55+
56+ _general_description = { \
57+ GenericBackend.BACKEND_NAME: "backend_gnote", \
58+ GenericBackend.BACKEND_HUMAN_NAME: _("Gnote"), \
59+ GenericBackend.BACKEND_AUTHORS: ["Luca Invernizzi"], \
60+ GenericBackend.BACKEND_TYPE: GenericBackend.TYPE_READWRITE, \
61+ GenericBackend.BACKEND_DESCRIPTION: \
62+ _("This backend can synchronize all or part of your Gnote"
63+ " notes in GTG. If you decide it would be handy to"
64+ " have one of your notes in your TODO list, just tag it "
65+ "with the tag you have chosen (you'll configure it later"
66+ "), and it will appear in GTG."),\
67+ }
68+
69+ _static_parameters = { \
70+ GenericBackend.KEY_ATTACHED_TAGS: {\
71+ GenericBackend.PARAM_TYPE: GenericBackend.TYPE_LIST_OF_STRINGS, \
72+ GenericBackend.PARAM_DEFAULT_VALUE: ["@GTG-Gnote"]}, \
73+ }
74+
75+ _BUS_ADDRESS = ("org.gnome.Gnote",
76+ "/org/gnome/Gnote/RemoteControl",
77+ "org.gnome.Gnote.RemoteControl")
78
79=== added file 'GTG/backends/backend_tomboy.py'
80--- GTG/backends/backend_tomboy.py 1970-01-01 00:00:00 +0000
81+++ GTG/backends/backend_tomboy.py 2010-08-26 23:33:46 +0000
82@@ -0,0 +1,60 @@
83+# -*- coding: utf-8 -*-
84+# -----------------------------------------------------------------------------
85+# Getting Things Gnome! - a personal organizer for the GNOME desktop
86+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
87+#
88+# This program is free software: you can redistribute it and/or modify it under
89+# the terms of the GNU General Public License as published by the Free Software
90+# Foundation, either version 3 of the License, or (at your option) any later
91+# version.
92+#
93+# This program is distributed in the hope that it will be useful, but WITHOUT
94+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
95+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
96+# details.
97+#
98+# You should have received a copy of the GNU General Public License along with
99+# this program. If not, see <http://www.gnu.org/licenses/>.
100+# -----------------------------------------------------------------------------
101+
102+'''
103+The tomboy backend. The actual backend is all in GenericTomboy, since it's
104+shared with the Gnote backend.
105+'''
106+
107+from GTG.backends.genericbackend import GenericBackend
108+from GTG import _
109+from GTG.backends.generictomboy import GenericTomboy
110+
111+
112+
113+class Backend(GenericTomboy):
114+ '''
115+ A simple class that adds some description to the GenericTomboy class.
116+ It's done this way since Tomboy and Gnote backends have different
117+ descriptions and Dbus addresses but the same backend behind them.
118+ '''
119+
120+
121+ _general_description = { \
122+ GenericBackend.BACKEND_NAME: "backend_tomboy", \
123+ GenericBackend.BACKEND_HUMAN_NAME: _("Tomboy"), \
124+ GenericBackend.BACKEND_AUTHORS: ["Luca Invernizzi"], \
125+ GenericBackend.BACKEND_TYPE: GenericBackend.TYPE_READWRITE, \
126+ GenericBackend.BACKEND_DESCRIPTION: \
127+ _("This backend can synchronize all or part of your Tomboy"
128+ " notes in GTG. If you decide it would be handy to"
129+ " have one of your notes in your TODO list, just tag it "
130+ "with the tag you have chosen (you'll configure it later"
131+ "), and it will appear in GTG."),\
132+ }
133+
134+ _static_parameters = { \
135+ GenericBackend.KEY_ATTACHED_TAGS: {\
136+ GenericBackend.PARAM_TYPE: GenericBackend.TYPE_LIST_OF_STRINGS, \
137+ GenericBackend.PARAM_DEFAULT_VALUE: ["@GTG-Tomboy"]}, \
138+ }
139+
140+ _BUS_ADDRESS = ("org.gnome.Tomboy",
141+ "/org/gnome/Tomboy/RemoteControl",
142+ "org.gnome.Tomboy.RemoteControl")
143
144=== added file 'GTG/backends/generictomboy.py'
145--- GTG/backends/generictomboy.py 1970-01-01 00:00:00 +0000
146+++ GTG/backends/generictomboy.py 2010-08-26 23:33:46 +0000
147@@ -0,0 +1,583 @@
148+# -*- coding: utf-8 -*-
149+# -----------------------------------------------------------------------------
150+# Getting Things Gnome! - a personal organizer for the GNOME desktop
151+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
152+#
153+# This program is free software: you can redistribute it and/or modify it under
154+# the terms of the GNU General Public License as published by the Free Software
155+# Foundation, either version 3 of the License, or (at your option) any later
156+# version.
157+#
158+# This program is distributed in the hope that it will be useful, but WITHOUT
159+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
160+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
161+# details.
162+#
163+# You should have received a copy of the GNU General Public License along with
164+# this program. If not, see <http://www.gnu.org/licenses/>.
165+# -----------------------------------------------------------------------------
166+
167+'''
168+Contains the Backend class for both Tomboy and Gnote
169+'''
170+#Note: To introspect tomboy, execute:
171+# qdbus org.gnome.Tomboy /org/gnome/Tomboy/RemoteControl
172+
173+import os
174+import threading
175+import uuid
176+import dbus
177+import datetime
178+import unicodedata
179+
180+from GTG.tools.testingmode import TestingMode
181+from GTG.tools.borg import Borg
182+from GTG.backends.genericbackend import GenericBackend
183+from GTG.backends.backendsignals import BackendSignals
184+from GTG.backends.syncengine import SyncEngine, SyncMeme
185+from GTG.tools.logger import Log
186+from GTG.tools.watchdog import Watchdog
187+from GTG.tools.interruptible import interruptible
188+from GTG.tools.tags import extract_tags_from_text
189+
190+
191+
192+class GenericTomboy(GenericBackend):
193+ '''Backend class for Tomboy/Gnote'''
194+
195+
196+###############################################################################
197+### Backend standard methods ##################################################
198+###############################################################################
199+
200+ def __init__(self, parameters):
201+ """
202+ See GenericBackend for an explanation of this function.
203+ """
204+ super(GenericTomboy, self).__init__(parameters)
205+ #loading the saved state of the synchronization, if any
206+ self.data_path = os.path.join('backends/tomboy/', \
207+ "sync_engine-" + self.get_id())
208+ self.sync_engine = self._load_pickled_file(self.data_path, \
209+ SyncEngine())
210+ #if the backend is being tested, we connect to a different DBus
211+ # interface to avoid clashing with a running instance of Tomboy
212+ if TestingMode().get_testing_mode():
213+ #just used for testing purposes
214+ self.BUS_ADDRESS = \
215+ self._parameters["use this fake connection instead"]
216+ else:
217+ self.BUS_ADDRESS = self._BUS_ADDRESS
218+ #we let some time pass before considering a tomboy task for importing,
219+ # as the user may still be editing it. Here, we store the Timer objects
220+ # that will execute after some time after each tomboy signal.
221+ #NOTE: I'm not sure if this is the case anymore (but it shouldn't hurt
222+ # anyway). (invernizzi)
223+ self._tomboy_setting_timers = {}
224+
225+ def initialize(self):
226+ '''
227+ See GenericBackend for an explanation of this function.
228+ Connects to the session bus and sets the callbacks for bus signals
229+ '''
230+ super(GenericTomboy, self).initialize()
231+ with self.DbusWatchdog(self):
232+ bus = dbus.SessionBus()
233+ bus.add_signal_receiver(self.on_note_saved,
234+ dbus_interface = self.BUS_ADDRESS[2],
235+ signal_name = "NoteSaved")
236+ bus.add_signal_receiver(self.on_note_deleted,
237+ dbus_interface = self.BUS_ADDRESS[2],
238+ signal_name = "NoteDeleted")
239+
240+ @interruptible
241+ def start_get_tasks(self):
242+ '''
243+ See GenericBackend for an explanation of this function.
244+ Gets all the notes from Tomboy and sees if they must be added in GTG
245+ (and, if so, it adds them).
246+ '''
247+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
248+ with self.DbusWatchdog(self):
249+ tomboy_notes = [note_id for note_id in \
250+ tomboy.ListAllNotes()]
251+ #adding the new ones
252+ for note in tomboy_notes:
253+ self.cancellation_point()
254+ self._process_tomboy_note(note)
255+ #checking if some notes have been deleted while GTG was not running
256+ stored_notes_ids = self.sync_engine.get_all_remote()
257+ for note in set(stored_notes_ids).difference(set(tomboy_notes)):
258+ self.on_note_deleted(note, None)
259+
260+ def save_state(self):
261+ '''Saves the state of the synchronization'''
262+ self._store_pickled_file(self.data_path, self.sync_engine)
263+
264+ def quit(self, disable = False):
265+ '''
266+ See GenericBackend for an explanation of this function.
267+ '''
268+ def quit_thread():
269+ while True:
270+ try:
271+ [key, timer] = \
272+ self._tomboy_setting_timers.iteritems().next()
273+ except StopIteration:
274+ break
275+ timer.cancel()
276+ del self._tomboy_setting_timers[key]
277+ threading.Thread(target = quit_thread).start()
278+ super(GenericTomboy, self).quit(disable)
279+
280+###############################################################################
281+### Something got removed #####################################################
282+###############################################################################
283+
284+ @interruptible
285+ def on_note_deleted(self, note, something):
286+ '''
287+ Callback, executed when a tomboy note is deleted.
288+ Deletes the related GTG task.
289+
290+ @param note: the id of the Tomboy note
291+ @param something: not used, here for signal callback compatibility
292+ '''
293+ with self.datastore.get_backend_mutex():
294+ self.cancellation_point()
295+ try:
296+ tid = self.sync_engine.get_local_id(note)
297+ except KeyError:
298+ return
299+ if self.datastore.has_task(tid):
300+ self.datastore.request_task_deletion(tid)
301+ self.break_relationship(remote_id = note)
302+
303+ @interruptible
304+ def remove_task(self, tid):
305+ '''
306+ See GenericBackend for an explanation of this function.
307+ '''
308+ with self.datastore.get_backend_mutex():
309+ self.cancellation_point()
310+ try:
311+ note = self.sync_engine.get_remote_id(tid)
312+ except KeyError:
313+ return
314+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
315+ with self.DbusWatchdog(self):
316+ if tomboy.NoteExists(note):
317+ tomboy.DeleteNote(note)
318+ self.break_relationship(local_id = tid)
319+
320+ def _exec_lost_syncability(self, tid, note):
321+ '''
322+ Executed when a relationship between tasks loses its syncability
323+ property. See SyncEngine for an explanation of that.
324+ This function finds out which object (task/note) is the original one
325+ and which is the copy, and deletes the copy.
326+
327+ @param tid: a GTG task tid
328+ @param note: a tomboy note id
329+ '''
330+ self.cancellation_point()
331+ meme = self.sync_engine.get_meme_from_remote_id(note)
332+ #First of all, the relationship is lost
333+ self.sync_engine.break_relationship(remote_id = note)
334+ if meme.get_origin() == "GTG":
335+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
336+ with self.DbusWatchdog(self):
337+ tomboy.DeleteNote(note)
338+ else:
339+ self.datastore.request_task_deletion(tid)
340+
341+###############################################################################
342+### Process tasks #############################################################
343+###############################################################################
344+
345+ def _process_tomboy_note(self, note):
346+ '''
347+ Given a tomboy note, finds out if it must be synced to a GTG note and,
348+ if so, it carries out the synchronization (by creating or updating a GTG
349+ task, or deleting itself if the related task has been deleted)
350+
351+ @param note: a Tomboy note id
352+ '''
353+ with self.datastore.get_backend_mutex():
354+ self.cancellation_point()
355+ is_syncable = self._tomboy_note_is_syncable(note)
356+ with self.DbusWatchdog(self):
357+ action, tid = self.sync_engine.analyze_remote_id(note, \
358+ self.datastore.has_task, \
359+ self._tomboy_note_exists, is_syncable)
360+ Log.debug("processing tomboy (%s, %s)" % (action, is_syncable))
361+
362+ if action == SyncEngine.ADD:
363+ tid = str(uuid.uuid4())
364+ task = self.datastore.task_factory(tid)
365+ self._populate_task(task, note)
366+ self.record_relationship(local_id = tid,\
367+ remote_id = note, \
368+ meme = SyncMeme(task.get_modified(),
369+ self.get_modified_for_note(note),
370+ self.get_id()))
371+ self.datastore.push_task(task)
372+
373+ elif action == SyncEngine.UPDATE:
374+ task = self.datastore.get_task(tid)
375+ meme = self.sync_engine.get_meme_from_remote_id(note)
376+ newest = meme.which_is_newest(task.get_modified(),
377+ self.get_modified_for_note(note))
378+ if newest == "remote":
379+ self._populate_task(task, note)
380+ meme.set_local_last_modified(task.get_modified())
381+ meme.set_remote_last_modified(\
382+ self.get_modified_for_note(note))
383+ self.save_state()
384+
385+ elif action == SyncEngine.REMOVE:
386+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
387+ with self.DbusWatchdog(self):
388+ tomboy.DeleteNote(note)
389+ try:
390+ self.sync_engine.break_relationship(remote_id = note)
391+ except KeyError:
392+ pass
393+
394+ elif action == SyncEngine.LOST_SYNCABILITY:
395+ self._exec_lost_syncability(tid, note)
396+
397+ @interruptible
398+ def set_task(self, task):
399+ '''
400+ See GenericBackend for an explanation of this function.
401+ '''
402+ self.cancellation_point()
403+ is_syncable = self._gtg_task_is_syncable_per_attached_tags(task)
404+ tid = task.get_id()
405+ with self.datastore.get_backend_mutex():
406+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
407+ with self.DbusWatchdog(self):
408+ action, note = self.sync_engine.analyze_local_id(tid, \
409+ self.datastore.has_task, tomboy.NoteExists, \
410+ is_syncable)
411+ Log.debug("processing gtg (%s, %d)" % (action, is_syncable))
412+
413+ if action == SyncEngine.ADD:
414+ #GTG allows multiple tasks with the same name,
415+ #Tomboy doesn't. we need to handle the renaming
416+ #manually
417+ title = task.get_title()
418+ duplicate_counter = 1
419+ with self.DbusWatchdog(self):
420+ note = tomboy.CreateNamedNote(title)
421+ while note == "":
422+ duplicate_counter += 1
423+ note = tomboy.CreateNamedNote(title + "(%d)" %
424+ duplicate_counter)
425+ if duplicate_counter != 1:
426+ #if we needed to rename, we have to rename also
427+ # the gtg task
428+ task.set_title(title + " (%d)" % duplicate_counter)
429+
430+ self._populate_note(note, task)
431+ self.record_relationship( \
432+ local_id = tid, remote_id = note, \
433+ meme = SyncMeme(task.get_modified(),
434+ self.get_modified_for_note(note),
435+ "GTG"))
436+
437+ elif action == SyncEngine.UPDATE:
438+ meme = self.sync_engine.get_meme_from_local_id(\
439+ task.get_id())
440+ newest = meme.which_is_newest(task.get_modified(),
441+ self.get_modified_for_note(note))
442+ if newest == "local":
443+ self._populate_note(note, task)
444+ meme.set_local_last_modified(task.get_modified())
445+ meme.set_remote_last_modified(\
446+ self.get_modified_for_note(note))
447+ self.save_state()
448+
449+ elif action == SyncEngine.REMOVE:
450+ self.datastore.request_task_deletion(tid)
451+ try:
452+ self.sync_engine.break_relationship(local_id = tid)
453+ self.save_state()
454+ except KeyError:
455+ pass
456+
457+ elif action == SyncEngine.LOST_SYNCABILITY:
458+ self._exec_lost_syncability(tid, note)
459+
460+###############################################################################
461+### Helper methods ############################################################
462+###############################################################################
463+
464+ @interruptible
465+ def on_note_saved(self, note):
466+ '''
467+ Callback, executed when a tomboy note is saved by Tomboy itself.
468+ Updates the related GTG task (or creates one, if necessary).
469+
470+ @param note: the id of the Tomboy note
471+ '''
472+ self.cancellation_point()
473+ #NOTE: we let some seconds pass before executing the real callback, as
474+ # the editing of the Tomboy note may still be in progress
475+ @interruptible
476+ def _execute_on_note_saved(self, note):
477+ self.cancellation_point()
478+ try:
479+ del self._tomboy_setting_timers[note]
480+ except:
481+ pass
482+ self._process_tomboy_note(note)
483+ self.save_state()
484+
485+ try:
486+ self._tomboy_setting_timers[note].cancel()
487+ except KeyError:
488+ pass
489+ finally:
490+ timer =threading.Timer(5, _execute_on_note_saved,
491+ args = (self, note))
492+ self._tomboy_setting_timers[note] = timer
493+ timer.start()
494+
495+ def _tomboy_note_is_syncable(self, note):
496+ '''
497+ Returns True if this tomboy note should be synced into GTG tasks.
498+
499+ @param note: the note id
500+ @returns Boolean
501+ '''
502+ attached_tags = self.get_attached_tags()
503+ if GenericBackend.ALLTASKS_TAG in attached_tags:
504+ return True
505+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
506+ with self.DbusWatchdog(self):
507+ content = tomboy.GetNoteContents(note)
508+ syncable = False
509+ for tag in attached_tags:
510+ try:
511+ content.index(tag)
512+ syncable = True
513+ break
514+ except ValueError:
515+ pass
516+ return syncable
517+
518+ def _tomboy_note_exists(self, note):
519+ '''
520+ Returns True if a tomboy note exists with the given id.
521+
522+ @param note: the note id
523+ @returns Boolean
524+ '''
525+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
526+ with self.DbusWatchdog(self):
527+ return tomboy.NoteExists(note)
528+
529+ def get_modified_for_note(self, note):
530+ '''
531+ Returns the modification time for the given note id.
532+
533+ @param note: the note id
534+ @returns datetime.datetime
535+ '''
536+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
537+ with self.DbusWatchdog(self):
538+ return datetime.datetime.fromtimestamp( \
539+ tomboy.GetNoteChangeDate(note))
540+
541+ def _tomboy_split_title_and_text(self, content):
542+ '''
543+ Tomboy does not have a "getTitle" and "getText" functions to get the
544+ title and the text of a note separately. Instead, it has a getContent
545+ function, that returns both of them.
546+ This function splits up the output of getContent into a title string and
547+ a text string.
548+
549+ @param content: a string, the result of a getContent call
550+ @returns list: a list composed by [title, text]
551+ '''
552+ try:
553+ end_of_title = content.index('\n')
554+ except ValueError:
555+ return content, unicode("")
556+ title = content[: end_of_title]
557+ if len(content) > end_of_title:
558+ return title, content[end_of_title +1 :]
559+ else:
560+ return title, unicode("")
561+
562+ def _populate_task(self, task, note):
563+ '''
564+ Copies the content of a Tomboy note into a task.
565+
566+ @param task: a GTG Task
567+ @param note: a Tomboy note
568+ '''
569+ #add tags objects (it's not enough to have @tag in the text to add a
570+ # tag
571+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
572+ with self.DbusWatchdog(self):
573+ content = tomboy.GetNoteContents(note)
574+ #update the tags list
575+ task.set_only_these_tags(extract_tags_from_text(content))
576+ #extract title and text
577+ [title, text] = self._tomboy_split_title_and_text(unicode(content))
578+ #Tomboy speaks unicode, we don't
579+ title = unicodedata.normalize('NFKD', title).encode('ascii', 'ignore')
580+ text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore')
581+ task.set_title(title)
582+ task.set_text(text)
583+ task.add_remote_id(self.get_id(), note)
584+
585+ def _populate_note(self, note, task):
586+ '''
587+ Copies the content of a task into a Tomboy note.
588+
589+ @param note: a Tomboy note
590+ @param task: a GTG Task
591+ '''
592+ title = task.get_title()
593+ tested_title = title
594+ duplicate_counter = 1
595+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
596+ with self.DbusWatchdog(self):
597+ tomboy.SetNoteContents(note, title + '\n' + \
598+ task.get_excerpt(strip_tags = False))
599+
600+ def break_relationship(self, *args, **kwargs):
601+ '''
602+ Proxy method for SyncEngine.break_relationship, which also saves the
603+ state of the synchronization.
604+ '''
605+ #tomboy passes Dbus.String objects, which are not pickable. We convert
606+ # those to unicode
607+ kwargs["remote_id"] = unicode(kwargs["remote_id"])
608+ try:
609+ self.sync_engine.break_relationship(*args, **kwargs)
610+ #we try to save the state at each change in the sync_engine:
611+ #it's slower, but it should avoid widespread task
612+ #duplication
613+ self.save_state()
614+ except KeyError:
615+ pass
616+
617+ def record_relationship(self, *args, **kwargs):
618+ '''
619+ Proxy method for SyncEngine.break_relationship, which also saves the
620+ state of the synchronization.
621+ '''
622+ #tomboy passes Dbus.String objects, which are not pickable. We convert
623+ # those to unicode
624+ kwargs["remote_id"] = unicode(kwargs["remote_id"])
625+
626+ self.sync_engine.record_relationship(*args, **kwargs)
627+ #we try to save the state at each change in the sync_engine:
628+ #it's slower, but it should avoid widespread task
629+ #duplication
630+ self.save_state()
631+
632+###############################################################################
633+### Connection handling #######################################################
634+###############################################################################
635+
636+
637+
638+ class TomboyConnection(Borg):
639+ '''
640+ TomboyConnection creates a connection to TOMBOY via DBUS and
641+ handles all the possible exceptions.
642+ It is a class that can be used with a with statement.
643+ Example:
644+ with self.TomboyConnection(self, *self.BUS_ADDRESS) as tomboy:
645+ #do something
646+ '''
647+
648+
649+ def __init__(self, backend, bus_name, bus_path, bus_interface):
650+ '''
651+ Sees if a TomboyConnection object already exists. If so, since we
652+ are inheriting from a Borg object, the initialization already took
653+ place.
654+ If not, it tries to connect to Tomboy via Dbus. If the connection
655+ is not possible, the user is notified about it.
656+
657+ @param backend: a reference to a Backend
658+ @param bus_name: the DBUS address of Tomboy
659+ @param bus_path: the DBUS path of Tomboy RemoteControl
660+ @param bus_interface: the DBUS address of Tomboy RemoteControl
661+ '''
662+ super(GenericTomboy.TomboyConnection, self).__init__()
663+ if hasattr(self, "tomboy_connection_is_ok") and \
664+ self.tomboy_connection_is_ok:
665+ return
666+ self.backend = backend
667+ with GenericTomboy.DbusWatchdog(backend):
668+ bus = dbus.SessionBus()
669+ obj = bus.get_object(bus_name, bus_path)
670+ self.tomboy = dbus.Interface(obj, bus_interface)
671+ self.tomboy_connection_is_ok = True
672+
673+ def __enter__(self):
674+ '''
675+ Returns the Tomboy connection
676+
677+ @returns dbus.Interface
678+ '''
679+ return self.tomboy
680+
681+ def __exit__(self, exception_type, value, traceback):
682+ '''
683+ Checks the state of the connection.
684+ If something went wrong for the connection, notifies the user.
685+
686+ @param exception_type: the type of exception that occurred, or
687+ None
688+ @param value: the instance of the exception occurred, or None
689+ @param traceback: the traceback of the error
690+ @returns: False if some exception must be re-raised.
691+ '''
692+ if isinstance(value, dbus.DBusException):
693+ self.tomboy_connection_is_ok = False
694+ self.backend.quit(disable = True)
695+ BackendSignals().backend_failed(self.backend.get_id(), \
696+ BackendSignals.ERRNO_DBUS)
697+ else:
698+ return False
699+ return True
700+
701+
702+
703+ class DbusWatchdog(Watchdog):
704+ '''
705+ A simple watchdog to detect stale dbus connections
706+ '''
707+
708+
709+ def __init__(self, backend):
710+ '''
711+ Simple constructor, which sets _when_taking_too_long as the function
712+ to run when the connection is taking too long.
713+
714+ @param backend: a Backend object
715+ '''
716+ self.backend = backend
717+ super(GenericTomboy.DbusWatchdog, self).__init__(3, \
718+ self._when_taking_too_long)
719+
720+ def _when_taking_too_long(self):
721+ '''
722+ Function that is executed when the Dbus connection seems to be
723+ hanging. It disables the backend and signals the error to the user.
724+ '''
725+ Log.error("Dbus connection is taking too long for the Tomboy/Gnote"
726+ "backend!")
727+ self.backend.quit(disable = True)
728+ BackendSignals().backend_failed(self.backend.get_id(), \
729+ BackendSignals.ERRNO_DBUS)
730+
731
732=== added file 'GTG/tests/test_backend_tomboy.py'
733--- GTG/tests/test_backend_tomboy.py 1970-01-01 00:00:00 +0000
734+++ GTG/tests/test_backend_tomboy.py 2010-08-26 23:33:46 +0000
735@@ -0,0 +1,397 @@
736+# -*- coding: utf-8 -*-
737+# -----------------------------------------------------------------------------
738+# Getting Things Gnome! - a personal organizer for the GNOME desktop
739+# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
740+#
741+# This program is free software: you can redistribute it and/or modify it under
742+# the terms of the GNU General Public License as published by the Free Software
743+# Foundation, either version 3 of the License, or (at your option) any later
744+# version.
745+#
746+# This program is distributed in the hope that it will be useful, but WITHOUT
747+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
748+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
749+# details.
750+#
751+# You should have received a copy of the GNU General Public License along with
752+# this program. If not, see <http://www.gnu.org/licenses/>.
753+# -----------------------------------------------------------------------------
754+
755+'''
756+Tests for the tomboy backend
757+'''
758+
759+import os
760+import sys
761+import errno
762+import unittest
763+import uuid
764+import signal
765+import time
766+import math
767+import dbus
768+import gobject
769+import random
770+import threading
771+import dbus.glib
772+import dbus.service
773+import tempfile
774+from datetime import datetime
775+from dbus.mainloop.glib import DBusGMainLoop
776+
777+from GTG.core.datastore import DataStore
778+from GTG.backends import BackendFactory
779+from GTG.backends.genericbackend import GenericBackend
780+
781+
782+
783+class TestBackendTomboy(unittest.TestCase):
784+ '''
785+ Tests for the tomboy backend.
786+ '''
787+
788+
789+ def setUp(self):
790+ self.spawn_fake_tomboy_server()
791+ #only the test process should go further, the dbus server one should
792+ #stop here
793+ if not self.child_process_pid: return
794+ #we create a custom dictionary listening to the server, and register it
795+ # in GTG.
796+ additional_dic = {}
797+ additional_dic["use this fake connection instead"] = \
798+ (FakeTomboy.BUS_NAME,
799+ FakeTomboy.BUS_PATH,
800+ FakeTomboy.BUS_INTERFACE)
801+ additional_dic[GenericBackend.KEY_ATTACHED_TAGS] = \
802+ [GenericBackend.ALLTASKS_TAG]
803+ additional_dic[GenericBackend.KEY_DEFAULT_BACKEND] = True
804+ dic = BackendFactory().get_new_backend_dict('backend_tomboy',
805+ additional_dic)
806+ self.datastore = DataStore()
807+ self.backend = self.datastore.register_backend(dic)
808+ #waiting for the "start_get_tasks" to settle
809+ time.sleep(1)
810+ #we create a dbus session to speak with the server
811+ self.bus = dbus.SessionBus()
812+ obj = self.bus.get_object(FakeTomboy.BUS_NAME, FakeTomboy.BUS_PATH)
813+ self.tomboy = dbus.Interface(obj, FakeTomboy.BUS_INTERFACE)
814+
815+ def spawn_fake_tomboy_server(self):
816+ #the fake tomboy server has to be in a different process,
817+ #otherwise it will lock on the GIL.
818+ #For details, see
819+ #http://lists.freedesktop.org/archives/dbus/2007-January/006921.html
820+
821+ #we use a lockfile to make sure the server is running before we start
822+ # the test
823+ lockfile_fd, lockfile_path = tempfile.mkstemp()
824+ self.child_process_pid = os.fork()
825+ if self.child_process_pid:
826+ #we wait in polling that the server has been started
827+ while True:
828+ try:
829+ fd = os.open(lockfile_path, os.O_CREAT | os.O_EXCL | os.O_RDWR)
830+ except OSError, e:
831+ if e.errno != errno.EEXIST:
832+ raise
833+ time.sleep(0.3)
834+ continue
835+ os.close(fd)
836+ break
837+ else:
838+ FakeTomboy()
839+ os.close(lockfile_fd)
840+ os.unlink(lockfile_path)
841+
842+ def tearDown(self):
843+ if not self.child_process_pid: return
844+ self.datastore.save(quit = True)
845+ print "QUIT"
846+ time.sleep(0.5)
847+ self.tomboy.FakeQuit()
848+ self.bus.close()
849+ os.kill(self.child_process_pid, signal.SIGKILL)
850+ os.waitpid(self.child_process_pid, 0)
851+
852+ def test_everything(self):
853+ '''
854+ '''
855+ #we cannot use separate test functions because we only want a single
856+ # FakeTomboy dbus server running
857+ if not self.child_process_pid: return
858+ for function in dir(self):
859+ if function.startswith("TEST_"):
860+ getattr(self, function)()
861+ self.tomboy.Reset()
862+ for tid in self.datastore.get_all_tasks():
863+ self.datastore.request_task_deletion(tid)
864+ time.sleep(0.1)
865+
866+ def TEST_processing_tomboy_notes(self):
867+ self.backend.set_attached_tags([GenericBackend.ALLTASKS_TAG])
868+ #adding a note
869+ note = self.tomboy.CreateNamedNote(str(uuid.uuid4()))
870+ self.backend._process_tomboy_note(note)
871+ self.assertEqual(len(self.datastore.get_all_tasks()), 1)
872+ tid = self.backend.sync_engine.sync_memes.get_local_id(note)
873+ task = self.datastore.get_task(tid)
874+ #re-adding that (should not change anything)
875+ self.backend._process_tomboy_note(note)
876+ self.assertEqual(len(self.datastore.get_all_tasks()), 1)
877+ self.assertEqual( \
878+ self.backend.sync_engine.sync_memes.get_local_id(note), tid)
879+ #removing the note and updating gtg
880+ self.tomboy.DeleteNote(note)
881+ self.backend.set_task(task)
882+ self.assertEqual(len(self.datastore.get_all_tasks()), 0)
883+
884+ def TEST_set_task(self):
885+ self.backend.set_attached_tags([GenericBackend.ALLTASKS_TAG])
886+ #adding a task
887+ task = self.datastore.requester.new_task()
888+ task.set_title("title")
889+ self.backend.set_task(task)
890+ self.assertEqual(len(self.tomboy.ListAllNotes()), 1)
891+ note = self.tomboy.ListAllNotes()[0]
892+ self.assertEqual(str(self.tomboy.GetNoteTitle(note)), task.get_title())
893+ #re-adding that (should not change anything)
894+ self.backend.set_task(task)
895+ self.assertEqual(len(self.tomboy.ListAllNotes()), 1)
896+ self.assertEqual(note, self.tomboy.ListAllNotes()[0])
897+ #removing the task and updating tomboy
898+ self.datastore.request_task_deletion(task.get_id())
899+ self.backend._process_tomboy_note(note)
900+ self.assertEqual(len(self.tomboy.ListAllNotes()), 0)
901+
902+ def TEST_update_newest(self):
903+ self.backend.set_attached_tags([GenericBackend.ALLTASKS_TAG])
904+ task = self.datastore.requester.new_task()
905+ task.set_title("title")
906+ self.backend.set_task(task)
907+ note = self.tomboy.ListAllNotes()[0]
908+ gtg_modified = task.get_modified()
909+ tomboy_modified = self._modified_string_to_datetime( \
910+ self.tomboy.GetNoteChangeDate(note))
911+ #no-one updated, nothing should happen
912+ self.backend.set_task(task)
913+ self.assertEqual(gtg_modified, task.get_modified())
914+ self.assertEqual(tomboy_modified, \
915+ self._modified_string_to_datetime( \
916+ self.tomboy.GetNoteChangeDate(note)))
917+ #we update the GTG task
918+ UPDATED_GTG_TITLE = "UPDATED_GTG_TITLE"
919+ task.set_title(UPDATED_GTG_TITLE)
920+ self.backend.set_task(task)
921+ self.assertTrue(gtg_modified < task.get_modified())
922+ self.assertTrue(tomboy_modified <=
923+ self._modified_string_to_datetime( \
924+ self.tomboy.GetNoteChangeDate(note)))
925+ self.assertEqual(task.get_title(), UPDATED_GTG_TITLE)
926+ self.assertEqual(self.tomboy.GetNoteTitle(note), UPDATED_GTG_TITLE)
927+ gtg_modified = task.get_modified()
928+ tomboy_modified = self._modified_string_to_datetime( \
929+ self.tomboy.GetNoteChangeDate(note))
930+ #we update the TOMBOY task
931+ UPDATED_TOMBOY_TITLE = "UPDATED_TOMBOY_TITLE"
932+ #the resolution of tomboy notes changed time is 1 second, so we need
933+ # to wait. This *shouldn't* be needed in the actual code because
934+ # tomboy signals are always a few seconds late.
935+ time.sleep(1)
936+ self.tomboy.SetNoteContents(note, UPDATED_TOMBOY_TITLE)
937+ self.backend._process_tomboy_note(note)
938+ self.assertTrue(gtg_modified <= task.get_modified())
939+ self.assertTrue(tomboy_modified <=
940+ self._modified_string_to_datetime( \
941+ self.tomboy.GetNoteChangeDate(note)))
942+ self.assertEqual(task.get_title(), UPDATED_TOMBOY_TITLE)
943+ self.assertEqual(self.tomboy.GetNoteTitle(note), UPDATED_TOMBOY_TITLE)
944+
945+ def TEST_processing_tomboy_notes_with_tags(self):
946+ self.backend.set_attached_tags(['@a'])
947+ #adding a not syncable note
948+ note = self.tomboy.CreateNamedNote("title" + str(uuid.uuid4()))
949+ self.backend._process_tomboy_note(note)
950+ self.assertEqual(len(self.datastore.get_all_tasks()), 0)
951+ #re-adding that (should not change anything)
952+ self.backend._process_tomboy_note(note)
953+ self.assertEqual(len(self.datastore.get_all_tasks()), 0)
954+ #adding a tag to that note
955+ self.tomboy.SetNoteContents(note, "something with @a")
956+ self.backend._process_tomboy_note(note)
957+ self.assertEqual(len(self.datastore.get_all_tasks()), 1)
958+ #removing the tag and resyncing
959+ self.tomboy.SetNoteContents(note, "something with no tags")
960+ self.backend._process_tomboy_note(note)
961+ self.assertEqual(len(self.datastore.get_all_tasks()), 0)
962+ #adding a syncable note
963+ note = self.tomboy.CreateNamedNote("title @a" + str(uuid.uuid4()))
964+ self.backend._process_tomboy_note(note)
965+ self.assertEqual(len(self.datastore.get_all_tasks()), 1)
966+ tid = self.backend.sync_engine.sync_memes.get_local_id(note)
967+ task = self.datastore.get_task(tid)
968+ #re-adding that (should not change anything)
969+ self.backend._process_tomboy_note(note)
970+ self.assertEqual(len(self.datastore.get_all_tasks()), 1)
971+ self.assertEqual( \
972+ self.backend.sync_engine.sync_memes.get_local_id(note), tid)
973+ #removing the note and updating gtg
974+ self.tomboy.DeleteNote(note)
975+ self.backend.set_task(task)
976+ self.assertEqual(len(self.datastore.get_all_tasks()), 0)
977+
978+ def TEST_set_task_with_tags(self):
979+ self.backend.set_attached_tags(['@a'])
980+ #adding a not syncable task
981+ task = self.datastore.requester.new_task()
982+ task.set_title("title")
983+ self.backend.set_task(task)
984+ self.assertEqual(len(self.tomboy.ListAllNotes()), 0)
985+ #making that task syncable
986+ task.set_title("something else")
987+ task.add_tag("@a")
988+ self.backend.set_task(task)
989+ self.assertEqual(len(self.tomboy.ListAllNotes()), 1)
990+ note = self.tomboy.ListAllNotes()[0]
991+ self.assertEqual(str(self.tomboy.GetNoteTitle(note)), task.get_title())
992+ #re-adding that (should not change anything)
993+ self.backend.set_task(task)
994+ self.assertEqual(len(self.tomboy.ListAllNotes()), 1)
995+ self.assertEqual(note, self.tomboy.ListAllNotes()[0])
996+ #removing the syncable property and updating tomboy
997+ task.remove_tag("@a")
998+ self.backend.set_task(task)
999+ self.assertEqual(len(self.tomboy.ListAllNotes()), 0)
1000+
1001+ def TEST_multiple_task_same_title(self):
1002+ self.backend.set_attached_tags(['@a'])
1003+ how_many_tasks = int(math.ceil(20 * random.random()))
1004+ for iteration in xrange(0, how_many_tasks):
1005+ task = self.datastore.requester.new_task()
1006+ task.set_title("title")
1007+ task.add_tag('@a')
1008+ self.backend.set_task(task)
1009+ self.assertEqual(len(self.tomboy.ListAllNotes()), how_many_tasks)
1010+
1011+ def _modified_string_to_datetime(self, modified_string):
1012+ return datetime.fromtimestamp(modified_string)
1013+
1014+
1015+def test_suite():
1016+ return unittest.TestLoader().loadTestsFromTestCase(TestBackendTomboy)
1017+
1018+
1019+
1020+class FakeTomboy(dbus.service.Object):
1021+ """
1022+ D-Bus service object that mimics TOMBOY
1023+ """
1024+
1025+
1026+ #We don't directly use the tomboy dbus path to avoid conflicts
1027+ # if tomboy is running during the test
1028+
1029+ BUS_NAME = "Fake.Tomboy"
1030+ BUS_PATH = "/Fake/Tomboy"
1031+ BUS_INTERFACE = "Fake.Tomboy.RemoteControl"
1032+
1033+ def __init__(self):
1034+ # Attach the object to D-Bus
1035+ DBusGMainLoop(set_as_default=True)
1036+ self.bus = dbus.SessionBus()
1037+ bus_name = dbus.service.BusName(self.BUS_NAME, bus = self.bus)
1038+ dbus.service.Object.__init__(self, bus_name, self.BUS_PATH)
1039+ self.notes = {}
1040+ threading.Thread(target = self.fake_main_loop).start()
1041+
1042+ @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="s")
1043+ def GetNoteContents(self, note):
1044+ return self.notes[note]['content']
1045+
1046+ @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="b")
1047+ def NoteExists(self, note):
1048+ return self.notes.has_key(note)
1049+
1050+ @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="d")
1051+ def GetNoteChangeDate(self, note):
1052+ return self.notes[note]['changed']
1053+
1054+ @dbus.service.method(BUS_INTERFACE, in_signature="ss")
1055+ def SetNoteContents(self, note, text):
1056+ self.fake_update_note(note)
1057+ self.notes[note]['content'] = text
1058+
1059+ @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="s")
1060+ def GetNoteTitle(self, note):
1061+ return self._GetNoteTitle(note)
1062+
1063+ def _GetNoteTitle(self, note):
1064+ content = self.notes[note]['content']
1065+ try:
1066+ end_of_title = content.index('\n')
1067+ except ValueError:
1068+ return content
1069+ return content[:end_of_title]
1070+
1071+ @dbus.service.method(BUS_INTERFACE, in_signature="s")
1072+ def DeleteNote(self, note):
1073+ del self.notes[note]
1074+
1075+ @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="s")
1076+ def CreateNamedNote(self, title):
1077+ #this is to mimic the way tomboy handles title clashes
1078+ if self._FindNote(title) != '':
1079+ return ''
1080+ note = str(uuid.uuid4())
1081+ self.notes[note] = {'content': title}
1082+ self.fake_update_note(note)
1083+ return note
1084+
1085+ @dbus.service.method(BUS_INTERFACE, in_signature="s", out_signature="s")
1086+ def FindNote(self, title):
1087+ return self._FindNote(title)
1088+
1089+ def _FindNote(self, title):
1090+ for note in self.notes:
1091+ if self._GetNoteTitle(note) == title:
1092+ return note
1093+ return ''
1094+
1095+ @dbus.service.method(BUS_INTERFACE, out_signature = "as")
1096+ def ListAllNotes(self):
1097+ return list(self.notes)
1098+
1099+ @dbus.service.signal(BUS_INTERFACE, signature='s')
1100+ def NoteSaved(self, note):
1101+ pass
1102+
1103+ @dbus.service.signal(BUS_INTERFACE, signature='s')
1104+ def NoteDeleted(self, note):
1105+ pass
1106+
1107+###############################################################################
1108+### Function with the fake_ prefix are here to assist in testing, they do not
1109+### need to be present in the real class
1110+###############################################################################
1111+
1112+ def fake_update_note(self, note):
1113+ self.notes[note]['changed'] = time.mktime(datetime.now().timetuple())
1114+
1115+ def fake_main_loop(self):
1116+ gobject.threads_init()
1117+ dbus.glib.init_threads()
1118+ self.main_loop = gobject.MainLoop()
1119+ self.main_loop.run()
1120+
1121+ @dbus.service.method(BUS_INTERFACE)
1122+ def Reset(self):
1123+ self.notes = {}
1124+
1125+ @dbus.service.method(BUS_INTERFACE)
1126+ def FakeQuit(self):
1127+ threading.Timer(0.2, self._fake_quit).start()
1128+
1129+ def _fake_quit(self):
1130+ self.main_loop.quit()
1131+ sys.exit(0)
1132+
1133
1134=== added file 'data/icons/hicolor/scalable/apps/backend_gnote.png'
1135Binary 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-26 23:33:46 +0000 differ
1136=== added file 'data/icons/hicolor/scalable/apps/backend_tomboy.png'
1137Binary 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-26 23:33:46 +0000 differ

Subscribers

People subscribed via source and target branches

to status/vote changes: