Subject: [PATCH] Journal Volumes Backup and Restore

From: Martin Abente <[email-masked]>
Date: Tue, 22 Jun 2010 15:59:13 -0400

Add a basic backup and restore feature for the Sugar Journal.
It provides:

- Generic Backup and Restore dialog GUI.
- Process manager class as an abstraction layer between the dialog and
  backup/restore scripts. (Allowing to work with many backup and restore
  technologies, using the same GUI, with no need for script rewrite).
- Basic file system Volume Restore and Backup scripts implemented in Python.
- New backup and restore options for journal volumes palettes.

This patch is based on Esteban Arias (Plan Ceibal) Volume Backup and Restore
patch, with a few changes:

- Refactor original Backup dialog class into a generic dialog class.
- Create specialized VolumeBackupDialog and VolumeRestoreDialog subclasses.
- Rewrite backup and restore scripts in python for an easier sugar interaction.
- Add backup identification helpers to jarabe.journal.misc.
---
 bin/Makefile.am                       |    4 +-
 bin/journal-backup-volume             |   57 ++++++++
 bin/journal-restore-volume            |   67 +++++++++
 src/jarabe/journal/Makefile.am        |    3 +-
 src/jarabe/journal/misc.py            |   27 ++++
 src/jarabe/journal/processdialog.py   |  248 +++++++++++++++++++++++++++++++++
 src/jarabe/journal/volumestoolbar.py  |    5 +-
 src/jarabe/model/Makefile.am          |    3 +-
 src/jarabe/model/processmanagement.py |   98 +++++++++++++
 src/jarabe/view/palettes.py           |   44 ++++++
 10 files changed, 551 insertions(+), 5 deletions(-)
 create mode 100644 bin/journal-backup-volume
 create mode 100644 bin/journal-restore-volume
 create mode 100644 src/jarabe/journal/processdialog.py
 create mode 100644 src/jarabe/model/processmanagement.py

diff --git a/bin/Makefile.am b/bin/Makefile.am
index 05a9215..8cc87b5 100644
--- a/bin/Makefile.am
+++ b/bin/Makefile.am
@@ -5,7 +5,9 @@ python_scripts =		\
 	sugar-install-bundle	\
 	sugar-launch		\
 	sugar-session		\
-	sugar-ui-check
+	sugar-ui-check		\
+	journal-backup-volume		\
+	journal-restore-volume
 
 bin_SCRIPTS = 			\
 	sugar			\
diff --git a/bin/journal-backup-volume b/bin/journal-backup-volume
new file mode 100644
index 0000000..4f3ec8a
--- /dev/null
+++ b/bin/journal-backup-volume
@@ -0,0 +1,57 @@
+#!/usr/bin/env python
+# Copyright (C) 2010, Paraguay Educa <[email-masked]>
+#
+# 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/>.
+#
+
+import os
+import sys
+import subprocess
+import logging
+
+from sugar import env
+#from sugar.datastore import datastore
+
+backup_identifier = sys.argv[2]
+volume_path = sys.argv[1]
+
+if len(sys.argv) != 3:
+    print 'Usage: %s <volume_path> <backup_identifier>' % sys.argv[0]
+    exit(1)
+
+logging.debug('Backup started')
+
+backup_path = os.path.join(volume_path, 'backup', backup_identifier)
+
+if not os.path.exists(backup_path):
+    os.makedirs(backup_path)
+
+#datastore.freeze()
+#subprocess.call(['pkill', '-9', '-f', 'python.*datastore-service'])
+
+result = 0
+try:
+    cmd = ['tar', '-C', env.get_profile_path(), '-czf', \
+           os.path.join(backup_path, 'datastore.tar.gz'), 'datastore']
+
+    subprocess.check_call(cmd)
+
+except Exception, e:
+    logging.error('Backup failed: %s', str(e))
+    result = 1
+
+#datastore.thaw()
+
+logging.debug('Backup finished')
+exit(result)
diff --git a/bin/journal-restore-volume b/bin/journal-restore-volume
new file mode 100644
index 0000000..aa14ad0
--- /dev/null
+++ b/bin/journal-restore-volume
@@ -0,0 +1,67 @@
+#!/usr/bin/env python
+# Copyright (C) 2010, Paraguay Educa <[email-masked]>
+#
+# 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/>.
+#
+
+import os
+import sys
+import shutil
+import logging
+import subprocess
+
+from sugar import env
+#from sugar.datastore import datastore
+
+backup_identifier = sys.argv[2]
+volume_path = sys.argv[1]
+
+if len(sys.argv) != 3:
+    print 'Usage: %s <volume_path> <backup_identifier>' % sys.argv[0]
+    exit(1)
+
+logging.debug('Restore started')
+
+journal_path = os.path.join(env.get_profile_path(), 'datastore')
+backup_path = os.path.join(volume_path, 'backup', backup_identifier, 'datastore.tar.gz')
+
+if not os.path.exists(backup_path):
+    logging.error('Could not find backup file %s', backup_path)
+    exit(1)
+
+#datastore.freeze()
+subprocess.call(['pkill', '-9', '-f', 'python.*datastore-service'])
+
+result = 0
+try:
+    if os.path.exists(journal_path):
+        shutil.rmtree(journal_path)
+
+    subprocess.check_call(['tar', '-C', env.get_profile_path(), '-xzf', backup_path])
+
+except Exception, e:
+    logging.error('Restore failed: %s', str(e))
+    result = 1
+
+try:
+  shutil.rmtree(os.path.join(journal_path, 'index'))
+  os.remove(os.path.join(journal_path, 'index_updated'))
+  os.remove(os.path.join(journal_path, 'version'))
+except:
+  logging.debug('Restore has no index files')
+
+#datastore.thaw()
+
+logging.debug('Restore finished')
+exit(result)
diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am
index f4bf273..a760869 100644
--- a/src/jarabe/journal/Makefile.am
+++ b/src/jarabe/journal/Makefile.am
@@ -14,4 +14,5 @@ sugar_PYTHON =				\
 	model.py			\
 	objectchooser.py		\
 	palettes.py			\
-	volumestoolbar.py
+	volumestoolbar.py			\
+	processdialog.py
diff --git a/src/jarabe/journal/misc.py b/src/jarabe/journal/misc.py
index 24ad216..6e3cb95 100644
--- a/src/jarabe/journal/misc.py
+++ b/src/jarabe/journal/misc.py
@@ -1,4 +1,5 @@
 # Copyright (C) 2007, One Laptop Per Child
+# Copyright (C) 2010, Paraguay Educa <[email-masked]>
 #
 # 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
@@ -249,3 +250,29 @@ def get_icon_color(metadata):
         return XoColor(client.get_string('/desktop/sugar/user/color'))
     else:
         return XoColor(metadata['icon-color'])
+
+def get_backup_identifier():
+    serial_number = get_xo_serial()
+    if serial_number is None:
+        serial_number = get_nick()
+    return serial_number
+
+def get_xo_serial():
+    path = '/ofw/serial-number'
+
+    if os.access(path, os.R_OK) == 0:
+        return None
+
+    file_descriptor = open(path, 'r')
+    content = file_descriptor.read()
+    file_descriptor.close()
+
+    if content:
+        return content.strip()
+    else:
+        logging.error('No serial number at %s', path)
+        return None
+
+def get_nick():
+    client = gconf.client_get_default()
+    return client.get_string("/desktop/sugar/user/nick")
diff --git a/src/jarabe/journal/processdialog.py b/src/jarabe/journal/processdialog.py
new file mode 100644
index 0000000..8217973
--- /dev/null
+++ b/src/jarabe/journal/processdialog.py
@@ -0,0 +1,248 @@
+#!/usr/bin/env python
+# Copyright (C) 2010, Plan Ceibal <[email-masked]>
+# Copyright (C) 2010, Paraguay Educa <[email-masked]>
+#
+# 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/>.
+
+import gtk
+import gobject
+import gconf
+import logging
+
+from gettext import gettext as _
+from sugar.graphics import style
+from sugar.graphics.icon import Icon
+from sugar.graphics.xocolor import XoColor
+
+from jarabe.journal import misc
+from jarabe.model import shell
+from jarabe.model import processmanagement
+from jarabe.model.session import get_session_manager
+
+class ProcessDialog(gtk.Window):
+
+    __gtype_name__ = 'SugarProcessDialog'
+
+    def __init__(self, process_script='', process_params=[], restart_after=True):
+
+        #FIXME: Workaround limitations of Sugar core modal handling
+        shell_model = shell.get_model()
+        shell_model.set_zoom_level(shell_model.ZOOM_HOME)
+
+        gtk.Window.__init__(self)
+
+        self._process_script = processmanagement.find_and_absolutize(process_script)
+        self._process_params = process_params
+        self._restart_after = restart_after
+        self._start_message = _('Running')
+        self._failed_message = _('Failed')
+        self._finished_message = _('Finished')
+
+        self.set_border_width(style.LINE_WIDTH)
+        width = gtk.gdk.screen_width()
+        height = gtk.gdk.screen_height()
+        self.set_size_request(width, height)
+        self.set_position(gtk.WIN_POS_CENTER_ALWAYS)
+        self.set_decorated(False)
+        self.set_resizable(False)
+        self.set_modal(True)
+
+        self._colored_box = gtk.EventBox()
+        self._colored_box.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("white"))
+        self._colored_box.show()
+
+        self._vbox = gtk.VBox()
+        self._vbox.set_spacing(style.DEFAULT_SPACING)
+        self._vbox.set_border_width(style.GRID_CELL_SIZE)
+
+        self._colored_box.add(self._vbox)
+        self.add(self._colored_box)
+
+        self._setup_information()
+        self._setup_progress_bar()
+        self._setup_options()
+
+        self._vbox.show()
+
+        self.connect("realize", self.__realize_cb)
+
+        self._process_management = processmanagement.ProcessManagement()
+        self._process_management.connect('process-management-running', self._set_status_updated)
+        self._process_management.connect('process-management-started', self._set_status_started)
+        self._process_management.connect('process-management-finished', self._set_status_finished)
+        self._process_management.connect('process-management-failed', self._set_status_failed)
+
+    def _setup_information(self):
+        client = gconf.client_get_default()
+        color = XoColor(client.get_string('/desktop/sugar/user/color'))
+
+        self._icon = Icon(icon_name='activity-journal', pixel_size=style.XLARGE_ICON_SIZE, xo_color=color)
+        self._icon.show()
+
+        self._vbox.pack_start(self._icon, False)
+
+        self._title = gtk.Label()
+        self._title.modify_fg(gtk.STATE_NORMAL, style.COLOR_BLACK.get_gdk_color())
+        self._title.set_use_markup(True)
+        self._title.set_justify(gtk.JUSTIFY_CENTER)
+        self._title.show()
+
+        self._vbox.pack_start(self._title, False)
+
+        self._message = gtk.Label()
+        self._message.modify_fg(gtk.STATE_NORMAL, style.COLOR_BLACK.get_gdk_color())
+        self._message.set_use_markup(True)
+        self._message.set_line_wrap(True)
+        self._message.set_justify(gtk.JUSTIFY_CENTER)
+        self._message.show()
+
+        self._vbox.pack_start(self._message, True)
+
+    def _setup_options(self):
+        hbox = gtk.HBox(True, 3)
+        hbox.show()
+
+        icon = Icon(icon_name='dialog-ok')
+
+        self._start_button = gtk.Button()
+        self._start_button.set_image(icon)
+        self._start_button.set_label(_('Start'))
+        self._start_button.connect('clicked', self.__start_cb)
+        self._start_button.show()
+
+        icon = Icon(icon_name='dialog-cancel')
+
+        self._close_button = gtk.Button()
+        self._close_button.set_image(icon)
+        self._close_button.set_label(_('Cancel'))
+        self._close_button.connect('clicked', self.__close_cb)
+        self._close_button.show()
+
+        icon = Icon(icon_name='system-restart')
+
+        self._restart_button = gtk.Button()
+        self._restart_button.set_image(icon)
+        self._restart_button.set_label(_('Restart'))
+        self._restart_button.connect('clicked', self.__restart_cb)
+        self._restart_button.hide()
+
+        hbox.add(self._start_button)
+        hbox.add(self._close_button)
+        hbox.add(self._restart_button)
+
+        halign = gtk.Alignment(1, 0, 0, 0)
+        halign.show()
+        halign.add(hbox)
+
+        self._vbox.pack_start(halign, False, False, 3)
+
+    def _setup_progress_bar(self):
+        alignment = gtk.Alignment(xalign=0.5, yalign=0.5, xscale=0.5)
+        alignment.show()
+
+        self._progress_bar = gtk.ProgressBar(adjustment=None)
+        self._progress_bar.hide()
+
+        alignment.add(self._progress_bar)
+        self._vbox.pack_start(alignment)
+
+    def __realize_cb(self, widget):
+        self.window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG)
+        self.window.set_accept_focus(True)
+
+    def __close_cb(self, button):
+        self.destroy()
+
+    def __start_cb(self, button):
+        self._process_management.do_process([self._process_script] + self._process_params)
+
+    def __restart_cb(self, button):
+        session_manager = get_session_manager()
+        session_manager.logout()
+
+    def _set_status_started(self, model, data=None):
+        self._message.set_markup(self._start_message)
+
+        self._start_button.hide()
+        self._close_button.hide()
+
+        self._progress_bar.set_fraction(0.05)
+        self._progress_bar_handler = gobject.timeout_add(1000, self.__progress_bar_handler_cb)
+        self._progress_bar.show()
+
+    def __progress_bar_handler_cb(self):
+        self._progress_bar.pulse()
+        return True
+
+    def _set_status_updated(self, model, data):
+        pass
+
+    def _set_status_finished(self, model, data=None):
+        self._message.set_markup(self._finished_message)
+
+        self._progress_bar.hide()
+        self._start_button.hide()
+
+        if self._restart_after:
+            self._restart_button.show()
+        else:
+            self._close_button.show()
+
+    def _set_status_failed(self, model, error_message=''):
+        self._message.set_markup('%s %s' % (self._failed_message, error_message))
+
+        self._progress_bar.hide()
+        self._start_button.show()
+        self._close_button.show()
+
+        logging.error(error_message)
+
+
+class VolumeBackupDialog(ProcessDialog):
+
+    def __init__(self, volume_path):
+        ProcessDialog.__init__(self, 'journal-backup-volume', \
+                              [volume_path, misc.get_backup_identifier()], restart_after=False)
+
+        self._resetup_information(volume_path)
+
+    def _resetup_information(self, volume_path):
+        self._start_message = '%s %s. \n\n' % (_('Please wait, saving Journal content to'), volume_path) + \
+                              '<big><b>%s</b></big>' % _('Do not remove the storage device!')
+
+        self._finished_message = _('The Journal content has been saved.')
+
+        self._title.set_markup('<big><b>%s</b></big>' % _('Backup'))
+
+        self._message.set_markup('%s %s' % (_('Journal content will be saved to'), volume_path))
+
+class VolumeRestoreDialog(ProcessDialog):
+
+    def __init__(self, volume_path):
+        ProcessDialog.__init__(self, 'journal-restore-volume', \
+                              [volume_path, misc.get_backup_identifier()])
+
+        self._resetup_information(volume_path)
+
+    def _resetup_information(self, volume_path):
+        self._start_message = '%s %s. \n\n' % (_('Please wait, restoring Journal content from'), volume_path) + \
+                              '<big><b>%s</b></big>' % _('Do not remove the storage device!')
+
+        self._finished_message = _('The Journal content has been restored.')
+
+        self._title.set_markup('<big><b>%s</b></big>' % _('Restore'))
+
+        self._message.set_markup('%s %s.\n\n' % (_('Journal content will be restored from'), volume_path) + \
+                                 '<big><b>%s</b> %s</big>' % (_('Warning:'), _('Current Journal content will be deleted!')))
+
diff --git a/src/jarabe/journal/volumestoolbar.py b/src/jarabe/journal/volumestoolbar.py
index 74b974c..2e64fe2 100644
--- a/src/jarabe/journal/volumestoolbar.py
+++ b/src/jarabe/journal/volumestoolbar.py
@@ -27,7 +27,7 @@ from sugar.graphics.palette import Palette
 from sugar.graphics.xocolor import XoColor
 
 from jarabe.journal import model
-from jarabe.view.palettes import VolumePalette
+from jarabe.view.palettes import JournalVolumePalette
 
 class VolumesToolbar(gtk.Toolbar):
     __gtype_name__ = 'VolumesToolbar'
@@ -164,11 +164,12 @@ class VolumeButton(BaseButton):
         self.props.xo_color = color
 
     def create_palette(self):
-        palette = VolumePalette(self._mount)
+        palette = JournalVolumePalette(self._mount)
         #palette.props.invoker = FrameWidgetInvoker(self)
         #palette.set_group_id('frame')
         return palette
 
+
 class JournalButton(BaseButton):
     def __init__(self):
         BaseButton.__init__(self, mount_point='/')
diff --git a/src/jarabe/model/Makefile.am b/src/jarabe/model/Makefile.am
index e9f0700..8fdc552 100644
--- a/src/jarabe/model/Makefile.am
+++ b/src/jarabe/model/Makefile.am
@@ -15,4 +15,5 @@ sugar_PYTHON =			\
 	shell.py		\
 	screen.py		\
         session.py		\
-	sound.py
+	sound.py		\
+	processmanagement.py
diff --git a/src/jarabe/model/processmanagement.py b/src/jarabe/model/processmanagement.py
new file mode 100644
index 0000000..466e1f6
--- /dev/null
+++ b/src/jarabe/model/processmanagement.py
@@ -0,0 +1,98 @@
+# Copyright (C) 2010, Paraguay Educa <[email-masked]>
+# Copyright (C) 2010, Plan Ceibal <[email-masked]>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+
+import os
+import gobject
+import glib
+import gio
+
+from sugar import env
+from gettext import gettext as _
+
+BYTES_TO_READ = 100
+
+class ProcessManagement(gobject.GObject):
+
+    __gtype_name__ = 'ProcessManagement'
+
+    __gsignals__ = {
+        'process-management-running'    : (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([str])),
+        'process-management-started'    : (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])),
+        'process-management-finished'    : (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])),
+        'process-management-failed'    : (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([str]))
+    }
+
+    def __init__(self):
+        gobject.GObject.__init__(self)
+        self._running = False
+
+    def do_process(self, cmd):
+        self._run_cmd_async(cmd)
+
+    def _report_process_status(self, stream, result):
+        data = stream.read_finish(result)
+
+        if len(data):
+            self.emit('process-management-running', data)
+            stream.read_async(BYTES_TO_READ, self._report_process_status)
+
+    def _report_process_error(self, stream, result, concat_err=''):
+        data = stream.read_finish(result)
+        concat_err = concat_err + data
+
+        if len(data) == 0:
+                self.emit('process-management-failed', concat_err)
+        else:
+            stream.read_async(BYTES_TO_READ, self._report_process_error, user_data=concat_err)
+
+    def _notify_error(self, stderr):
+        stdin_stream = gio.unix.InputStream(stderr, True)
+        stdin_stream.read_async(BYTES_TO_READ, self._report_process_error)
+
+    def _notify_process_status(self, stdout):
+        stdin_stream = gio.unix.InputStream(stdout, True)
+        stdin_stream.read_async(BYTES_TO_READ, self._report_process_status)
+
+    def _run_cmd_async(self, cmd):
+        if self._running == False:
+            try:
+                pid, stdin, stdout, stderr = glib.spawn_async(cmd, flags=glib.SPAWN_DO_NOT_REAP_CHILD, standard_output=True, standard_error=True)
+                gobject.child_watch_add(pid, _handle_process_end, (self, stderr))
+            except Exception:
+                self.emit('process-management-failed', _("Error - Call process: ") + str(cmd))
+            else:
+                self._notify_process_status(stdout)
+                self._running  = True
+                self.emit('process-management-started')
+
+def _handle_process_end(pid, condition, (myself, stderr)):
+    myself._running = False
+
+    if os.WIFEXITED(condition) and\
+        os.WEXITSTATUS(condition) == 0:
+            myself.emit('process-management-finished')
+    else:
+        myself._notify_error(stderr)
+
+def find_and_absolutize(script_name):
+    paths = env.os.environ['PATH'].split(':')
+    for path in paths:
+        looking_path =  path + '/' + script_name
+        if env.os.path.isfile(looking_path):
+            return looking_path
+
+    return None
diff --git a/src/jarabe/view/palettes.py b/src/jarabe/view/palettes.py
index ad84f08..2fc4d5f 100644
--- a/src/jarabe/view/palettes.py
+++ b/src/jarabe/view/palettes.py
@@ -1,4 +1,6 @@
 # Copyright (C) 2008 One Laptop Per Child
+# Copyright (C) 2010, Plan Ceibal <[email-masked]>
+# Copyright (C) 2010, Paraguay Educa <[email-masked]>
 #
 # 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
@@ -31,6 +33,7 @@ from sugar.graphics.xocolor import XoColor
 from sugar.activity import activityfactory
 from sugar.activity.activityhandle import ActivityHandle
 
+from jarabe.journal.processdialog import VolumeBackupDialog, VolumeRestoreDialog
 from jarabe.model import shell
 from jarabe.view import launcher
 from jarabe.view.viewsource import setup_view_source
@@ -258,3 +261,44 @@ class VolumePalette(Palette):
         self._free_space_label.props.label = _('%(free_space)d MB Free') % \
                 {'free_space': free_space / (1024 * 1024)}
 
+
+class JournalVolumePalette(VolumePalette):
+
+    __gtype_name__ = 'JournalVolumePalette'
+
+    def __init__(self, mount):
+        VolumePalette.__init__(self, mount)
+
+        journal_separator = gtk.SeparatorMenuItem()
+        journal_separator.show()
+
+        self.menu.prepend(journal_separator)
+
+        icon = Icon(icon_name='transfer-from', icon_size=gtk.ICON_SIZE_MENU)
+        icon.show()
+
+        menu_item_journal_restore = MenuItem(_('Restore Journal'))
+        menu_item_journal_restore.set_image(icon)
+        menu_item_journal_restore.connect('activate', self.__journal_restore_activate_cb, mount.get_root().get_path())
+        menu_item_journal_restore.show()
+
+        self.menu.prepend(menu_item_journal_restore)
+
+        icon = Icon(icon_name='transfer-to', icon_size=gtk.ICON_SIZE_MENU)
+        icon.show()
+
+        menu_item_journal_backup = MenuItem(_('Backup Journal'))
+        menu_item_journal_backup.set_image(icon)
+        menu_item_journal_backup.connect('activate', self.__journal_backup_activate_cb, mount.get_root().get_path())
+        menu_item_journal_backup.show()
+
+        self.menu.prepend(menu_item_journal_backup)
+
+    def __journal_backup_activate_cb(self, menu_item, mount_path):
+        dialog = VolumeBackupDialog(mount_path)
+        dialog.show()
+
+    def __journal_restore_activate_cb(self, menu_item, mount_path):
+        dialog = VolumeRestoreDialog(mount_path)
+        dialog.show()
+
-- 
1.6.0.4