[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