[Sugar-devel] [PATCH] Add backup capability to Journal (using JEBs)

Sascha Silbe sascha-pgp at silbe.org
Sun Jul 4 17:12:10 EDT 2010


Add the ability to back up the Journal to a multi-entry Journal Entry Bundle
(JEB) on a mounted storage volume.

A new menu item in the Journals volume toolbar enables users to start a
backup to a specific volume. Progress will be presented in a separate window;
the entire system (including the Journal) remains accessible and responsive.

Doing a high-level backup has the advantage that we don't need any special
support in the data store to ensure its integrity while either backup or
restore are running.

Since JEB is a transport format, this can also be used to exchange the
entire data store with another system (that doesn't need to run
sugar-datastore).

Signed-off-by: Sascha Silbe <sascha-pgp at silbe.org>

---
 src/jarabe/journal/Makefile.am        |    1 +
 src/jarabe/journal/backup.py          |  183 +++++++++++++++++++++++++++++++++
 src/jarabe/journal/journalactivity.py |  106 +++++++++++++++++++
 src/jarabe/journal/palettes.py        |   22 ++++
 src/jarabe/journal/volumestoolbar.py  |    6 +-
 5 files changed, 316 insertions(+), 2 deletions(-)

diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am
index f4bf273..9cae87c 100644
--- a/src/jarabe/journal/Makefile.am
+++ b/src/jarabe/journal/Makefile.am
@@ -1,6 +1,7 @@
 sugardir = $(pythondir)/jarabe/journal
 sugar_PYTHON =				\
 	__init__.py			\
+	backup.py			\
 	detailview.py			\
 	expandedentry.py		\
 	journalactivity.py		\
diff --git a/src/jarabe/journal/backup.py b/src/jarabe/journal/backup.py
new file mode 100644
index 0000000..bb3f01f
--- /dev/null
+++ b/src/jarabe/journal/backup.py
@@ -0,0 +1,183 @@
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3
+# as published by the Free Software Foundation.
+#
+# 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/>.
+"""
+Run a data store back up asynchronously.
+"""
+
+from __future__ import with_statement
+
+import logging
+import os
+import tempfile
+import threading
+import time
+import zipfile
+
+from gettext import gettext as _
+
+import gobject
+try:
+    import json
+except ImportError:
+    import simplejson as json
+
+from sugar import profile
+from jarabe.journal import model
+
+
+class AsyncBackup(gobject.GObject):
+    """
+    Run a data store backup asynchronously.
+    """
+
+    _METADATA_JSON_NAME = '_metadata.json'
+
+    __gsignals__ = {
+        'progress': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
+            ([int, int])),
+        'done': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])),
+        'error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([object])),
+    }
+
+    def __init__(self, mount_point):
+        gobject.GObject.__init__(self)
+        self._mount_point = mount_point
+        self._result_set = model.find({}, 100)
+        self._position = 0
+        self._path = None
+        self._bundle = None
+        self._do_abort = False
+        self._thread = threading.Thread(target=self._thread_run)
+        self._lock = threading.Lock()
+
+    def start(self):
+        """Start the backup process."""
+        self._thread.start()
+
+    def abort(self):
+        """Abort the backup and clean up."""
+        with self._lock:
+            self._do_abort = True
+
+        self._thread.join()
+        os.remove(self._path)
+
+    @property
+    def num_entries(self):
+        """Get the total number of entries to back up."""
+        with self._lock:
+            return self._result_set.get_length()
+
+    @property
+    def position(self):
+        """Get the number of already backed up entries."""
+        with self._lock:
+            return self._position
+
+    def _thread_run(self):
+        try:
+            with self._lock:
+                self._path, self._bundle = self._create_bundle()
+
+            while True:
+                with self._lock:
+                    if self._do_abort:
+                        return
+
+                    num_entries = self._result_set.get_length()
+                    self._update_progress(self._position, num_entries)
+                    if self._position >= num_entries:
+                        break
+
+                    # FIXME: model.DataStoreResultSet isn't a stable iterator
+                    self._result_set.seek(self._position)
+                    entry = self._result_set.read()
+
+                self._add_entry(self._bundle, entry)
+
+                with self._lock:
+                    self._position += 1
+
+            with self._lock:
+                self._bundle.close()
+                self._bundle = None
+
+            gobject.idle_add(lambda _: self.emit('done'),
+                gobject.PRIORITY_HIGH)
+
+        except Exception, exception:
+            logging.exception('Could not back up Journal:')
+            gobject.idle_add(lambda _: self.emit('error', exception),
+                gobject.PRIORITY_HIGH)
+
+        if self._bundle and not self._bundle.closed:
+            self._bundle.close()
+            self._bundle = None
+
+    def _update_progress(self, position, num_entries):
+        gobject.idle_add(lambda _: self.emit('progress', position,
+            num_entries), gobject.PRIORITY_HIGH)
+
+    def _create_bundle(self):
+        name = profile.get_nick_name().replace('/', ' ')
+        if '\0' in name:
+            raise ValueError('Invalid name')
+
+        key_hash = profile.get_profile().privkey_hash
+
+        date = time.strftime('%x')
+        prefix = _('Journal backup of %s (%s) on %s') % (name, key_hash, date)
+        fd, path = self._create_file(self._mount_point, prefix, '.xoj')
+        try:
+            return path, zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED)
+        finally:
+            os.close(fd)
+
+    def _create_file(self, directory, prefix, suffix):
+        path = '%s/%s%s' % (directory, prefix, suffix)
+        flags = os.O_CREAT | os.O_EXCL
+        mode = 0600
+        try:
+            return os.open(path, flags, mode), path
+
+        except OSError:
+            return tempfile.mkstemp(dir=directory, prefix=prefix + ' ',
+                suffix=suffix)
+
+    def _add_entry(self, bundle, entry):
+        if 'version_id' in entry:
+            object_id = (entry['tree_id'], entry['version_id'])
+            object_id_s = '%s,%s' % object_id
+        else:
+            object_id = entry['uid']
+            object_id_s = object_id
+
+        metadata = model.get(object_id)
+        data_path = model.get_file(object_id)
+        if data_path:
+            bundle.write(data_path, os.path.join(object_id_s, object_id_s))
+
+        for name, value in metadata.items():
+            is_binary = False
+            try:
+                value.encode('utf-8')
+            except UnicodeDecodeError:
+                is_binary = True
+
+            if is_binary or len(value) > 8192:
+                logging.debug('adding binary/large property %r', name)
+                bundle.writestr(os.path.join(object_id_s, str(name),
+                    object_id_s), value)
+                del metadata[name]
+
+        bundle.writestr(os.path.join(object_id_s, self._METADATA_JSON_NAME),
+            json.dumps(metadata))
diff --git a/src/jarabe/journal/journalactivity.py b/src/jarabe/journal/journalactivity.py
index 0559560..02689fe 100644
--- a/src/jarabe/journal/journalactivity.py
+++ b/src/jarabe/journal/journalactivity.py
@@ -21,6 +21,7 @@ import sys
 import traceback
 import uuid
 
+import gobject
 import gtk
 import dbus
 import statvfs
@@ -42,6 +43,8 @@ from jarabe.journal.journalentrybundle import JournalEntryBundle
 from jarabe.journal.objectchooser import ObjectChooser
 from jarabe.journal.modalalert import ModalAlert
 from jarabe.journal import model
+import jarabe.journal.backup
+
 
 J_DBUS_SERVICE = 'org.laptop.Journal'
 J_DBUS_INTERFACE = 'org.laptop.Journal'
@@ -100,6 +103,95 @@ class JournalActivityDBusService(dbus.service.Object):
     def ObjectChooserCancelled(self, chooser_id):
         pass
 
+
+class BackupView(Window):
+    """Journal view that displays the progress of a backup run"""
+
+    __gsignals__ = {
+        'close': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])),
+    }
+
+    def __init__(self, mount_point):
+        Window.__init__(self)
+        self.set_title(_('Backup'))
+
+        self._done = False
+        self._backup = jarabe.journal.backup.AsyncBackup(mount_point)
+        self._backup.connect('done', self._done_cb)
+        self._backup.connect('error', self._error_cb)
+        self._backup.connect('progress', self._progress_cb)
+
+        self._realized_sid = self.connect('realize', self.__realize_cb)
+        vbox = gtk.VBox(False)
+
+        label = gtk.Label(_('Backing up Journal to %s') % (mount_point, ))
+        label.show()
+        vbox.pack_start(label)
+
+        alignment = gtk.Alignment(xalign=0.5, yalign=0.5, xscale=0.5)
+        alignment.show()
+
+        self._progress_bar = gtk.ProgressBar()
+        self._progress_bar.show()
+        alignment.add(self._progress_bar)
+        vbox.add(alignment)
+
+        self._message_box = gtk.Label()
+        vbox.pack_start(self._message_box)
+
+        self._close_button = gtk.Button(_('Abort'))
+        self._close_button.connect('clicked', self._close_cb)
+        self._close_button.show()
+        button_box = gtk.HButtonBox()
+        button_box.show()
+        button_box.add(self._close_button)
+        vbox.pack_start(button_box, False)
+
+        vbox.show()
+        self.set_canvas(vbox)
+        self.show()
+        self._backup.start()
+
+    def __realize_cb(self, window):
+        wm.set_bundle_id(window.window, _BUNDLE_ID)
+        activity_id = activityfactory.create_activity_id()
+        wm.set_activity_id(window.window, str(activity_id))
+        self.disconnect(self._realized_sid)
+        self._realized_sid = None
+
+    def _update_progress(self, position, num_entries):
+        self._progress_bar.props.text = '%d / %d' % (position, num_entries)
+        self._progress_bar.props.fraction = float(position) / num_entries
+
+    def _progress_cb(self, backup, position, num_entries):
+        self._update_progress(position, num_entries)
+
+    def _done_cb(self, backup):
+        logging.debug('_done_cb')
+        self._done = True
+        self._close_button.set_label(_('Finish'))
+
+    def _error_cb(self, backup, exception):
+        if isinstance(exception, OSError):
+            self._show_error(_('Could not create backup: %s') % (
+                exception.strerror, ))
+        elif isinstance(exception, IOError):
+            self._show_error(_('Could not write backup: %s') % (
+                exception.strerror, ))
+        else:
+            self._show_error(str(exception))
+
+    def _show_error(self, message):
+        self._message_box.props.label = message
+        self._message_box.show()
+
+    def _close_cb(self, button):
+        if not self._done:
+            self._backup.abort()
+
+        self.emit('close')
+
+
 class JournalActivity(Window):
     def __init__(self):
         logging.debug("STARTUP: Loading the journal")
@@ -114,6 +206,7 @@ class JournalActivity(Window):
         self._main_toolbox = None
         self._detail_toolbox = None
         self._volumes_toolbar = None
+        self._backup_view = None
 
         self._setup_main_view()
         self._setup_secondary_view()
@@ -225,6 +318,19 @@ class JournalActivity(Window):
         self.set_canvas(self._secondary_view)
         self._secondary_view.show()
 
+    def start_backup(self, mount_point):
+        if self._backup_view is not None:
+            return
+
+        self._backup_view = BackupView(mount_point)
+        self._backup_view.connect('close', self._backup_view_close_cb)
+
+    def _backup_view_close_cb(self, backup_view):
+        self._backup_view.destroy()
+        self._backup_view = None
+        self._main_toolbox.search_toolbar.refresh_filters()
+        self._list_view.refresh()
+
     def show_object(self, object_id):
         metadata = model.get(object_id)
         if metadata is None:
diff --git a/src/jarabe/journal/palettes.py b/src/jarabe/journal/palettes.py
index 0e7702d..05c6ccd 100644
--- a/src/jarabe/journal/palettes.py
+++ b/src/jarabe/journal/palettes.py
@@ -34,6 +34,9 @@ from jarabe.model import filetransfer
 from jarabe.model import mimeregistry
 from jarabe.journal import misc
 from jarabe.journal import model
+import jarabe.journal.journalactivity
+from jarabe.view.palettes import VolumePalette
+
 
 class ObjectPalette(Palette):
 
@@ -238,3 +241,22 @@ class BuddyPalette(Palette):
                          icon=buddy_icon)
 
         # TODO: Support actions on buddies, like make friend, invite, etc.
+
+
+class JournalVolumePalette(VolumePalette):
+
+    def __init__(self, mount):
+        VolumePalette.__init__(self, mount)
+        backup_icon = Icon(icon_name='transfer-to',
+            icon_size=gtk.ICON_SIZE_MENU)
+        backup_icon.show()
+        backup_item = MenuItem(_('Backup Journal'))
+        backup_item.set_image(backup_icon)
+        backup_item.connect('activate', self.__backup_activate_cb)
+        backup_item.show()
+        self.menu.append(backup_item)
+
+    def __backup_activate_cb(self, backup_item):
+        mount_point = self._mount.get_root().get_path()
+        journal = jarabe.journal.journalactivity.get_journal()
+        journal.start_backup(mount_point)
diff --git a/src/jarabe/journal/volumestoolbar.py b/src/jarabe/journal/volumestoolbar.py
index 74b974c..a71951a 100644
--- a/src/jarabe/journal/volumestoolbar.py
+++ b/src/jarabe/journal/volumestoolbar.py
@@ -27,7 +27,8 @@ 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.journal.palettes import JournalVolumePalette
+
 
 class VolumesToolbar(gtk.Toolbar):
     __gtype_name__ = 'VolumesToolbar'
@@ -164,11 +165,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='/')
-- 
tg: (844baaf..) t/backup (depends on: upstream/master t/jeb-multi t/jeb-mime-fix)


More information about the Sugar-devel mailing list