diff options
author | Sascha Silbe <sascha-pgp@silbe.org> | 2010-08-29 18:39:28 (GMT) |
---|---|---|
committer | Sascha Silbe <sascha-pgp@silbe.org> | 2010-08-29 18:39:28 (GMT) |
commit | f1eb9444dd3e8f4beb338b8d0cc88aa750630095 (patch) | |
tree | 24ad621248b4a88cbac3271134647fe12d19a5c9 | |
parent | 0930e1c1ada119c22953508fab637ace26188d61 (diff) |
create Restore activity based on Backup
-rw-r--r-- | activity/Backup_icon.svg | 102 | ||||
-rw-r--r-- | activity/Restore_icon.svg | 30 | ||||
-rw-r--r-- | activity/activity.info | 12 | ||||
-rw-r--r-- | activity/mimetypes.xml | 7 | ||||
-rw-r--r-- | backup.py | 730 | ||||
-rw-r--r-- | icons/journal-export.svg | 100 | ||||
-rw-r--r-- | icons/journal-import.svg | 30 | ||||
-rw-r--r-- | restore.py | 577 |
8 files changed, 650 insertions, 938 deletions
diff --git a/activity/Backup_icon.svg b/activity/Backup_icon.svg deleted file mode 100644 index e72ef90..0000000 --- a/activity/Backup_icon.svg +++ /dev/null @@ -1,102 +0,0 @@ -<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd' [ - <!ENTITY stroke_color "#666666"> - <!ENTITY fill_color "#FFFFFF"> -]> -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - version="1.1" - id="Icon" - width="44.365" - height="42.918" - viewBox="0 0 44.365 42.918" - overflow="visible" - enable-background="new 0 0 44.365 42.918" - xml:space="preserve" - sodipodi:version="0.32" - inkscape:version="0.46" - sodipodi:docname="stock-open.svg" - inkscape:output_extension="org.inkscape.output.svg.inkscape"><metadata - id="metadata26"><rdf:RDF><cc:Work - rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs - id="defs24"><inkscape:perspective - sodipodi:type="inkscape:persp3d" - inkscape:vp_x="0 : 21.459 : 1" - inkscape:vp_y="0 : 1000 : 0" - inkscape:vp_z="44.365002 : 21.459 : 1" - inkscape:persp3d-origin="22.182501 : 14.306 : 1" - id="perspective28" /></defs><sodipodi:namedview - inkscape:window-height="975" - inkscape:window-width="1680" - inkscape:pageshadow="2" - inkscape:pageopacity="0.0" - guidetolerance="10.0" - gridtolerance="10.0" - objecttolerance="10.0" - borderopacity="1.0" - bordercolor="#666666" - pagecolor="#ffffff" - id="base" - showgrid="false" - inkscape:zoom="13.563637" - inkscape:cx="22.182502" - inkscape:cy="28.758498" - inkscape:window-x="0" - inkscape:window-y="25" - inkscape:current-layer="Icon" /> - - - -<g - id="g2414" - transform="translate(9.0634189,-3.9384509)"><g - transform="translate(2.6055351e-2,5.183866e-2)" - id="g2405"><g - id="g3" - transform="matrix(0.736868,0,0,0.7153409,0,12.146464)"> - <g - id="g5"> - <path - d="M 1.75,41.168 L 23.673,41.168 C 25.663,41.168 26.749,39.873 26.749,38.09 L 26.749,13.699 C 26.749,12.152 25.2,10.622 23.673,10.622 L 19.75,10.622" - id="path7" - style="fill:none;stroke:&stroke_color;;stroke-width:3.5;stroke-linejoin:round" /> - </g> -</g><g - id="g9" - transform="matrix(0.6976744,0,0,0.7277711,0,11.605042)"> - <g - id="g11"> - <path - d="M 19.75,31.859 C 19.75,33.502 18.548,34.723 16.673,35.478 L 1.75,41.168 L 1.75,10.623 L 16.673,2.935 C 18.664,2.583 19.75,3.686 19.75,5.469 L 19.75,31.859 z" - id="path13" - style="fill:none;stroke:&stroke_color;;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round" /> - </g> -</g><line - x1="5.5922894" - y1="39.528976" - x2="5.5922894" - y2="17.763372" - id="line15" - style="fill:none;stroke:&stroke_color;;stroke-width:2.70127702;stroke-linecap:round;stroke-linejoin:round" /></g><g - transform="matrix(0.5891883,0,0,0.6267357,6.6476984e-2,7.8435405)" - id="g17"> - - <line - style="fill:none;stroke:&stroke_color;;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round" - id="line19" - y2="11.828" - x2="32.536999" - y1="1.75" - x1="42.615002" /> - <polyline - style="fill:none;stroke:&stroke_color;;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round" - id="polyline21" - points="33.322,2.539 42.615,1.75 41.826,11.043 " /> -</g></g> -</svg> diff --git a/activity/Restore_icon.svg b/activity/Restore_icon.svg new file mode 100644 index 0000000..b6eb3a3 --- /dev/null +++ b/activity/Restore_icon.svg @@ -0,0 +1,30 @@ +<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd' [ + <!ENTITY stroke_color "#010101"> + <!ENTITY fill_color "#FFFFFF"> +]> +<svg enable-background="new 0 0 55 55" height="55px" id="Layer_1" version="1.1" viewBox="0 0 55 55" width="55px" x="0px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" y="0px"> +<g display="block" id="document-save"> + <g> + <g> + <g> + <path d="M6.736,49.002 h24.52c2.225,0,3.439-1.447,3.439-3.441V18.281c0-1.73-1.732-3.441-3.439-3.441h-4.389" fill="&fill_color;" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5"/> + </g> + </g> + <g> + <g> + <path d="M26.867,38.592 c0,1.836-1.345,3.201-3.441,4.047l-16.69,6.363V14.84l16.69-8.599c2.228-0.394,3.441,0.84,3.441,2.834V38.592z" fill="&fill_color;" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5"/> + </g> + </g> + <path d="M9.424,42.607 c0,0-1.351-0.543-2.702-0.543s-2.703,0.543-2.703,0.543" fill="none" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25"/> + <path d="M9.424,32.006 c0,0-1.239-0.543-2.815-0.543c-1.577,0-2.59,0.543-2.59,0.543" fill="none" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25"/> + <path d="M9.424,21.678 c0,0-1.125-0.544-2.927-0.544c-1.802,0-2.478,0.544-2.478,0.544" fill="none" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25"/> + + <line fill="none" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25" x1="13.209" x2="13.209" y1="46.533" y2="11.505"/> + + <g> + <line fill="none" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" x1="41.17" x2="52.441" y1="16.188" y2="4.917"/> + <polyline fill="none" points=" 51.562,15.306 41.17,16.188 42.053,5.794 " stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5"/> + </g> + </g> +</g> +</svg> diff --git a/activity/activity.info b/activity/activity.info index 21e76f1..2b6f2e3 100644 --- a/activity/activity.info +++ b/activity/activity.info @@ -1,9 +1,9 @@ [Activity] -name = Backup -activity_version = 2 -bundle_id = org.sugarlabs.Backup -exec = sugar-activity backup.BackupActivity -icon = Backup_icon -mime_types = +name = Restore +activity_version = 1 +bundle_id = org.sugarlabs.Restore +exec = sugar-activity restore.RestoreActivity +icon = Restore_icon +mime_types = application/vnd.sugar-multi-journal-entry license = GPLv3 diff --git a/activity/mimetypes.xml b/activity/mimetypes.xml new file mode 100644 index 0000000..c9e1bc5 --- /dev/null +++ b/activity/mimetypes.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info"> +<mime-type type="application/vnd.sugar-multi-journal-entry"> +<comment xml:lang="en">Multi-entry Sugar Journal Entry Bundle</comment> +<glob pattern="*.xmj"/> +</mime-type> +</mime-info> diff --git a/backup.py b/backup.py deleted file mode 100644 index 35d86ca..0000000 --- a/backup.py +++ /dev/null @@ -1,730 +0,0 @@ -#!/usr/bin/env python -# -# Author: Sascha Silbe <sascha-pgp@silbe.org> -# -# 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/>. -"""Backup. Activity to back up the Sugar Journal to external media. -""" - -import gettext -import logging -import os -import select -import statvfs -import sys -import tempfile -import time -import traceback -import zipfile - -import dbus -import gconf -import gobject -import gtk - -#from sugar.activity.widgets import ActivityToolbarButton -from sugar.activity.widgets import StopButton -from sugar.activity import activity -import sugar.env -from sugar.graphics.icon import CellRendererIcon -from sugar.graphics import style -from sugar.graphics.toolbutton import ToolButton -from sugar.graphics.toolbarbox import ToolbarBox -from sugar.graphics.xocolor import XoColor -import sugar.logger -from sugar import profile - -try: - import json -except ImportError: - import simplejson as json - - -DS_DBUS_SERVICE = "org.laptop.sugar.DataStore" -DS_DBUS_INTERFACE1 = "org.laptop.sugar.DataStore" -DS_DBUS_PATH1 = "/org/laptop/sugar/DataStore" -DS_DBUS_INTERFACE2 = "org.laptop.sugar.DataStore2" -DS_DBUS_PATH2 = "/org/laptop/sugar/DataStore2" - -HAL_SERVICE_NAME = 'org.freedesktop.Hal' -HAL_MANAGER_PATH = '/org/freedesktop/Hal/Manager' -HAL_MANAGER_IFACE = 'org.freedesktop.Hal.Manager' -HAL_DEVICE_IFACE = 'org.freedesktop.Hal.Device' -HAL_VOLUME_IFACE = 'org.freedesktop.Hal.Device.Volume' - - -def format_size(size): - if not size: - return _('Empty') - elif size < 10*1024: - return _('%4d B') % size - elif size < 10*1024**2: - return _('%4d KiB') % (size // 1024) - elif size < 10*1024**3: - return _('%4d MiB') % (size // 1024**2) - else: - return _('%4d GiB') % (size // 1024**3) - - -class BackupButton(ToolButton): - - def __init__(self, **kwargs): - ToolButton.__init__(self, 'journal-export', **kwargs) - self.props.tooltip = _('Backup Journal').encode('utf-8') - self.props.accelerator = '<Alt>b' - - -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, ([str])), - } - - def __init__(self, mount_point): - gobject.GObject.__init__(self) - self._mount_point = mount_point - self._path = None - self._bundle = None - self._child_pid = None - self._pipe_from_child = None - self._pipe_to_child = None - self._pipe_from_child_watch_id = None - self._num_entries = None - self._entries = None - self._data_store = None - self._user_name = profile.get_nick_name().replace('/', ' ') - self._key_hash = profile.get_profile().privkey_hash - - if '\0' in self._user_name: - raise ValueError('Invalid user name') - - - def start(self): - """Start the backup process.""" - to_child_read_fd, to_child_write_fd = os.pipe() - from_child_read_fd, from_child_write_fd = os.pipe() - - self._child_pid = os.fork() - if not self._child_pid: - os.close(from_child_read_fd) - os.close(to_child_write_fd) - self._pipe_from_child = os.fdopen(from_child_write_fd, 'w') - self._pipe_to_child = os.fdopen(to_child_read_fd, 'r') - self._child_run() - sys.exit(0) - else: - os.close(from_child_write_fd) - os.close(to_child_read_fd) - self._pipe_from_child = os.fdopen(from_child_read_fd, 'r') - self._pipe_to_child = os.fdopen(to_child_write_fd, 'w') - self._pipe_from_child_watch_id = gobject.io_add_watch( - self._pipe_from_child, - gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, - self._child_io_cb) - - def abort(self): - """Abort the backup and clean up.""" - self._pipe_to_child.write('abort\n') - self._parent_close() - - def _child_io_cb(self, source_, condition): - """Receive and handle message from child.""" - if condition in [gobject.IO_ERR, gobject.IO_HUP]: - logging.debug('error condition: %r', condition) - self.emit('error', - _('Lost connection to child process').encode('utf-8')) - self._parent_close() - return False - - status = self._read_line_from_child() - if status == 'progress': - position = int(self._read_line_from_child()) - num_entries = int(self._read_line_from_child()) - self.emit('progress', position, num_entries) - return True - - elif status == 'done': - self.emit('done') - self._parent_close() - return False - - elif status == 'error': - message = unicode(self._read_line_from_child(), 'utf-8') - trace = unicode(self._pipe_from_child.read().strip(), 'utf-8') - logging.error('Child reported error: %s\n%s', message, trace) - self.emit('error', message.encode('utf-8')) - self._parent_close() - return False - - else: - logging.error('Unknown status %r from child process', status) - self.emit('error', 'Unknown status %r from child process', status) - self.abort() - return False - - def _read_line_from_child(self): - """Read a line from the child process using low-level IO. - - This is a hack to work around the fact that file.readline() buffers - data without us knowing about it. If we call readline() a second - time when no data is buffered, it may block (=> the UI would hang). - If OTOH there is another line already in the buffer, we won't get - notified about it by select() as it already is in userspace. - There are cleaner ways to handle this (e.g. using the asyncore module), - but they are much more complex. - """ - line = [] - while True: - character = os.read(self._pipe_from_child.fileno(), 1) - if character == '\n': - return ''.join(line) - - line.append(character) - - def _parent_close(self): - """Close connections to child and wait for it.""" - gobject.source_remove(self._pipe_from_child_watch_id) - self._pipe_from_child.close() - self._pipe_to_child.close() - pid_, status = os.waitpid(self._child_pid, 0) - if os.WIFEXITED(status): - logging.debug('Child exited with rc=%d', os.WEXITSTATUS(status)) - elif os.WIFSIGNALED(status): - logging.debug('Child killed by signal %d', os.WTERMSIG(status)) - else: - logging.error('Sudden infant death syndrome') - - def _child_run(self): - """Main program of child.""" - try: - self._connect_to_data_store() - self._entries, self._num_entries = self._find_entries() - assert self._num_entries == len(self._entries) - self._path, self._bundle = self._create_bundle() - - for position, entry in enumerate(self._entries): - self._client_check_command() - - self._send_to_parent('progress\n%d\n%d\n' % (position, - self._num_entries)) - logging.debug('processing entry %r', entry) - self._add_entry(self._bundle, entry) - - self._send_to_parent('progress\n%d\n%d\n' % ( - self._num_entries, self._num_entries)) - self._bundle.fp.flush() - self._bundle.close() - self._send_to_parent('done\n') - - # pylint: disable=W0703 - except Exception, exception: - self._pipe_from_child.write('error\n') - message = unicode(exception).encode('utf-8') - self._pipe_from_child.write(message+'\n') - trace = unicode(traceback.format_exc()).encode('utf-8') - self._pipe_from_child.write(trace) - self._remove_bundle() - sys.exit(2) - - def _send_to_parent(self, message): - self._pipe_from_child.write(message) - self._pipe_from_child.flush() - - def _client_check_command(self): - """Check for and execute command from the parent.""" - in_ready, out_ready_, errors_on_ = select.select([self._pipe_to_child], - [], [], 0) - if not in_ready: - return - - command = self._pipe_to_child.readline().strip() - logging.debug('command %r received', command) - if command == 'abort': - self._remove_bundle() - sys.exit(1) - else: - raise ValueError('Unknown command %r' % (command, )) - - def _create_bundle(self): - """Create / open bundle (zip file) with unique file name.""" - date = time.strftime('%x') - if '/' in date: - date = time.strftime('%Y-%m-%d') - - prefix = _('Journal backup of %s (%s) on %s') % (self._user_name, - self._key_hash, date) - bundle_fd, path = self._create_file(self._mount_point, prefix, '.xmj') - try: - return path, zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) - finally: - os.close(bundle_fd) - - def _remove_bundle(self): - """Close bundle and remove it from permanent storage.""" - if self._path: - os.remove(self._path) - - if self._bundle and self._bundle.fp and not self._bundle.fp.closed: - self._bundle.close() - - def _create_file(self, directory, prefix, suffix): - """Create a unique file with given prefix and suffix in directory. - - Append random ASCII characters only if necessary. - """ - 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): - """Add data store entry identified by entry to bundle.""" - 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 = self._get_metadata(object_id) - data_path = self._get_data(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)) - - def _connect_to_data_store(self): - """Open a connection to a Sugar data store.""" - # We forked => need to use a private connection and make sure we - # never allow the main loop to run - # http://lists.freedesktop.org/archives/dbus/2007-April/007498.html - # http://lists.freedesktop.org/archives/dbus/2007-August/008359.html - bus = dbus.SessionBus(private=True) - try: - self._data_store = dbus.Interface(bus.get_object(DS_DBUS_SERVICE, - DS_DBUS_PATH2), DS_DBUS_INTERFACE2) - self._data_store.find({'tree_id': 'invalid'}, - {'metadata': ['tree_id']}) - logging.info('Data store with version support found') - return - - except dbus.DBusException: - logging.debug('No data store with version support found') - - self._data_store = dbus.Interface(bus.get_object(DS_DBUS_SERVICE, - DS_DBUS_PATH1), DS_DBUS_INTERFACE1) - self._data_store.find({'uid': 'invalid'}, ['uid']) - logging.info('Data store without version support found') - - def _find_entries(self): - """Retrieve a list of all entries from the data store.""" - if self._data_store.dbus_interface == DS_DBUS_INTERFACE2: - return self._data_store.find({}, {'metadata': - ['tree_id', 'version_id'], 'all_versions': True}, - timeout=5*60, byte_arrays=True) - else: - return self._data_store.find({}, ['uid'], byte_arrays=True) - - def _get_metadata(self, object_id): - """Return metadata for data store entry identified by object_id.""" - if self._data_store.dbus_interface == DS_DBUS_INTERFACE2: - tree_id, version_id = object_id - return self._data_store.find( - {'tree_id': tree_id, 'version_id': version_id}, {}, - byte_arrays=True)[0][0] - else: - return self._data_store.get_properties(object_id, byte_arrays=True) - - def _get_data(self, object_id): - """Return path to data for data store entry identified by object_id.""" - if self._data_store.dbus_interface == DS_DBUS_INTERFACE2: - tree_id, version_id = object_id - return self._data_store.get_data(tree_id, version_id, - byte_arrays=True) - else: - return self._data_store.get_filename(object_id, byte_arrays=True) - - -class BackupActivity(activity.Activity): - - _METADATA_JSON_NAME = '_metadata.json' - _MEDIA_COMBO_ICON_COLUMN = 0 - _MEDIA_COMBO_NAME_COLUMN = 1 - _MEDIA_COMBO_PATH_COLUMN = 2 - _MEDIA_COMBO_FREE_COLUMN = 3 - - def __init__(self, handle): - activity.Activity.__init__(self, handle, create_jobject=False) - self.max_participants = 1 - self._progress_bar = None - self._message_box = None - self._media_combo_model = None - self._media_combo = None - self._backup_button = None - self._backup = None - self._hal_devices = {} - client = gconf.client_get_default() - self._color = XoColor(client.get_string('/desktop/sugar/user/color')) - self._setup_widgets() - self._find_media() - - def read_file(self, file_path): - """We don't have any state to save in the Journal.""" - return - - def write_file(self, file_path): - """We don't have any state to save in the Journal.""" - return - - def close(self, skip_save=False): - """We don't have any state to save in the Journal.""" - activity.Activity.close(self, skip_save=True) - - def _setup_widgets(self): - self._setup_toolbar() - self._setup_main_view() - - def _setup_main_view(self): - vbox = gtk.VBox() - self.set_canvas(vbox) - vbox.show() - - def _setup_toolbar(self): - toolbar_box = ToolbarBox() - - self._media_combo_model = gtk.ListStore(str, str, str, str) - self._media_combo = gtk.ComboBox(self._media_combo_model) - icon_renderer = CellRendererIcon(self._media_combo) - icon_renderer.props.xo_color = self._color - icon_renderer.props.width = style.STANDARD_ICON_SIZE + \ - style.DEFAULT_SPACING - icon_renderer.props.height = style.STANDARD_ICON_SIZE - icon_renderer.props.size = style.STANDARD_ICON_SIZE - icon_renderer.props.xpad = style.DEFAULT_PADDING - icon_renderer.props.ypad = style.DEFAULT_PADDING - self._media_combo.pack_start(icon_renderer, False) - self._media_combo.add_attribute(icon_renderer, 'icon_name', - self._MEDIA_COMBO_ICON_COLUMN) - name_renderer = gtk.CellRendererText() - self._media_combo.pack_start(name_renderer, False) - self._media_combo.add_attribute(name_renderer, 'text', - self._MEDIA_COMBO_NAME_COLUMN) - free_renderer = gtk.CellRendererText() - self._media_combo.pack_start(free_renderer, False) - self._media_combo.add_attribute(free_renderer, 'text', - self._MEDIA_COMBO_FREE_COLUMN) - - tool_item = gtk.ToolItem() - tool_item.add(self._media_combo) - # FIXME: looks like plain GTK, not like Sugar - tooltip_text = _('Storage medium to store the backup on') - tool_item.set_tooltip_text(tooltip_text.encode('utf-8')) - toolbar_box.toolbar.insert(tool_item, -1) - - self._backup_button = BackupButton() - self._backup_button.connect('clicked', self._backup_cb) - self._backup_button.set_sensitive(False) - toolbar_box.toolbar.insert(self._backup_button, -1) - - separator = gtk.SeparatorToolItem() - separator.props.draw = False - separator.set_expand(True) - toolbar_box.toolbar.insert(separator, -1) - - stop_button = StopButton(self) - toolbar_box.toolbar.insert(stop_button, -1) - - self.set_toolbar_box(toolbar_box) - toolbar_box.show_all() - - def _backup_cb(self, button): - """Callback for Backup button.""" - if not len(self._media_combo_model): - return - - row = self._media_combo_model[self._media_combo.get_active()] - mount_point = row[self._MEDIA_COMBO_PATH_COLUMN] - self._setup_backup_view(mount_point) - self._start_backup(mount_point) - - def _start_backup(self, mount_point): - """Set up and start background worker process.""" - self._backup = AsyncBackup(mount_point) - self._backup.connect('progress', self._progress_cb) - self._backup.connect('error', self._error_cb) - self._backup.connect('done', self._done_cb) - self._backup.start() - - def _setup_backup_view(self, mount_point): - """Set up UI for showing feedback from worker process.""" - vbox = gtk.VBox(False) - - label_text = _('Backing up Journal to %s') % (mount_point, ) - label = gtk.Label(label_text.encode('utf-8')) - 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.props.text = _('Scanning Journal').encode('utf-8') - self._progress_bar.show() - alignment.add(self._progress_bar) - vbox.add(alignment) - - self._message_box = gtk.Label() - vbox.pack_start(self._message_box) - - # FIXME -# 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() - - def _progress_cb(self, backup, position, num_entries): - """Update progress bar with information from child process.""" - self._progress_bar.props.text = '%d / %d' % (position, num_entries) - self._progress_bar.props.fraction = float(position) / num_entries - - def _done_cb(self, backup): - """Backup finished.""" - logging.debug('_done_cb') -# self._close_button.set_label(_('Finish')) - - def _error_cb(self, backup, message): - """Receive error message from child process.""" - self._show_error(unicode(message, 'utf-8')) - - def _show_error(self, message): - """Present error message to user.""" - self._message_box.props.label = unicode(message).encode('utf-8') - self._message_box.show() - -# def _close_cb(self, button): -# if not self._done: -# self._backup.abort() - -# self.emit('close') - - def _find_media(self): - """Fill the combo box with available storage media. - - Also sets up a callback to keep the list current. - """ - try: - import gio - except ImportError: - return self._find_media_hal() - - volume_monitor = gio.volume_monitor_get() - volume_monitor.connect('mount-added', self._gio_mount_added_cb) - volume_monitor.connect('mount-removed', self._gio_mount_removed_cb) - for mount in volume_monitor.get_mounts(): - self._gio_mount_added_cb(volume_monitor, mount) - - def _gio_mount_added_cb(self, volume_monitor, mount): - """Handle notification from GIO that a medium was mounted.""" - icon_name = self._choose_icon_name(mount.get_icon().props.names) - path = mount.get_root().get_path() - name = mount.get_name() - self._add_medium(path, name, icon_name) - - def _gio_mount_removed_cb(self, volume_monitor, mount): - """Handle notification from GIO that a medium was unmounted.""" - path = mount.get_root().get_path() - self._remove_medium(path) - - def _find_media_hal(self): - """Use HAL to fill in the available storage media.""" - bus = dbus.SystemBus() - proxy = bus.get_object(HAL_SERVICE_NAME, HAL_MANAGER_PATH) - hal_manager = dbus.Interface(proxy, HAL_MANAGER_IFACE) - hal_manager.connect_to_signal('DeviceAdded', - self._hal_device_added_cb) - - for udi in hal_manager.FindDeviceByCapability('volume'): - self._hal_device_added_cb(udi) - - def _hal_device_added_cb(self, udi): - """Handle notification from GIO that a device was added.""" - bus = dbus.SystemBus() - device_object = bus.get_object(HAL_SERVICE_NAME, udi) - device = dbus.Interface(device_object, HAL_DEVICE_IFACE) - - # A just-added device might lack one of the attributes we're - # looking for, so we need to listen for changes on ALL devices. - device.connect_to_signal('PropertyModified', - lambda *args: self._hal_property_modified_cb(udi, *args)) - - try: - if device.GetProperty('volume.fsusage') != 'filesystem': - logging.debug('Ignoring non-filesystem UDI %s', udi) - return - except dbus.DBusException: - logging.debug('Ignoring UDI %s without volume.fsusage', udi) - return - - self._hal_try_device(device, udi) - - def _hal_try_device(self, device, udi): - """Possibly add device to UI.""" - if not device.GetProperty('volume.is_mounted'): - return - - path = device.GetProperty('volume.mount_point') - if path == '/': - return - - name = device.GetProperty('volume.label') - if not name: - name = device.GetProperty('volume.uuid') - - self._hal_devices[udi] = path - bus = dbus.SystemBus() - bus.add_signal_receiver(self._hal_device_removed_cb, - 'DeviceRemoved', - HAL_MANAGER_IFACE, HAL_SERVICE_NAME, - HAL_MANAGER_PATH, arg0=udi) - - self._add_medium(path, name, - self._choose_icon_name(self._hal_get_icons_for_volume(device))) - - def _hal_device_removed_cb(self, udi): - """Handle notification from GIO that a device was removed.""" - path = self._hal_devices.pop(udi, None) - if not path: - return - - self._remove_medium(path) - - def _hal_property_modified_cb(self, udi, count, changes): - """Handle notification from HAL that a property has changed.""" - if 'volume.is_mounted' in [change[0] for change in changes]: - logging.debug('volume.is_mounted changed for UDI %s', udi) - - bus = dbus.SystemBus() - device_object = bus.get_object(HAL_SERVICE_NAME, udi) - device = dbus.Interface(device_object, HAL_DEVICE_IFACE) - - # We can get multiple notifications, so need to figure out - # the current state and ignore non-changes. - if device.GetProperty('volume.is_mounted'): - if udi not in self._hal_devices: - self._hal_try_device(device, udi) - else: - logging.debug('Ignoring duplicate notification') - else: - if udi in self._hal_devices: - self._hal_device_removed_cb(udi) - else: - logging.debug('Ignoring duplicate notification') - else: - logging.debug('Ignoring change for UDI %s', udi) - - def _hal_get_icons_for_volume(self, device): - bus = dbus.SystemBus() - storage_udi = device.GetProperty('block.storage_device') - obj = bus.get_object(HAL_SERVICE_NAME, storage_udi) - storage_device = dbus.Interface(obj, HAL_DEVICE_IFACE) - - storage_drive_type = storage_device.GetProperty('storage.drive_type') - bus_type = storage_device.GetProperty('storage.bus') - if storage_drive_type == 'sd_mmc': - return ['media-flash-sd', 'media-flash-sd-mmc', 'media'] - elif bus_type == 'usb': - return ['media-flash-usb', 'media', 'media-disk'] - else: - return ['media', 'media-disk', 'media-flash-usb'] - - def _add_medium(self, path, medium_name, icon_name): - """Make storage medium selectable in the UI.""" - # FIXME: update space information periodically or at least after - # backup run - stat = os.statvfs(path) - free_space = stat[statvfs.F_BSIZE] * stat[statvfs.F_BAVAIL] -# total_space = stat[statvfs.F_BSIZE] * stat[statvfs.F_BLOCKS] - self._media_combo_model.append([icon_name, medium_name, path, - _('%s Free') % (format_size(free_space), )]) - self._backup_button.set_sensitive(True) - if self._media_combo.get_active() == -1: - self._media_combo.set_active(0) - - def _remove_medium(self, path): - """Remove storage medium from UI.""" - active = self._media_combo.get_active() - for position, row in enumerate(self._media_combo_model): - if path == row[self._MEDIA_COMBO_PATH_COLUMN]: - del self._media_combo_model[position] - - if not len(self._media_combo_model): - self._backup_button.set_sensitive(False) - return - - if position != active: - return - - self._media_combo.set_active(max(0, position-1)) - return - - logging.warning("Asked to remove %s from UI, but didn't find it!", - path) - - def _choose_icon_name(self, names): - """Choose the first valid icon name or fall back to a default name.""" - theme = gtk.icon_theme_get_default() - for name in names: - if theme.lookup_icon(name, gtk.ICON_SIZE_LARGE_TOOLBAR, 0): - return name - - return 'drive' - - -# pylint isn't smart enough for the gettext.install() magic -_ = lambda msg: msg -gettext.install('backup', 'po', unicode=True) -sugar.logger.start() diff --git a/icons/journal-export.svg b/icons/journal-export.svg deleted file mode 100644 index cb1baf1..0000000 --- a/icons/journal-export.svg +++ /dev/null @@ -1,100 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Generator: Adobe Illustrator 12.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 51448) --> -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - version="1.1" - id="Icon" - width="44.365" - height="42.918" - viewBox="0 0 44.365 42.918" - overflow="visible" - enable-background="new 0 0 44.365 42.918" - xml:space="preserve" - sodipodi:version="0.32" - inkscape:version="0.46" - sodipodi:docname="stock-open.svg" - inkscape:output_extension="org.inkscape.output.svg.inkscape"><metadata - id="metadata26"><rdf:RDF><cc:Work - rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs - id="defs24"><inkscape:perspective - sodipodi:type="inkscape:persp3d" - inkscape:vp_x="0 : 21.459 : 1" - inkscape:vp_y="0 : 1000 : 0" - inkscape:vp_z="44.365002 : 21.459 : 1" - inkscape:persp3d-origin="22.182501 : 14.306 : 1" - id="perspective28" /></defs><sodipodi:namedview - inkscape:window-height="975" - inkscape:window-width="1680" - inkscape:pageshadow="2" - inkscape:pageopacity="0.0" - guidetolerance="10.0" - gridtolerance="10.0" - objecttolerance="10.0" - borderopacity="1.0" - bordercolor="#666666" - pagecolor="#ffffff" - id="base" - showgrid="false" - inkscape:zoom="13.563637" - inkscape:cx="22.182502" - inkscape:cy="28.758498" - inkscape:window-x="0" - inkscape:window-y="25" - inkscape:current-layer="Icon" /> - - - -<g - id="g2414" - transform="translate(9.0634189,-3.9384509)"><g - transform="translate(2.6055351e-2,5.183866e-2)" - id="g2405"><g - id="g3" - transform="matrix(0.736868,0,0,0.7153409,0,12.146464)"> - <g - id="g5"> - <path - d="M 1.75,41.168 L 23.673,41.168 C 25.663,41.168 26.749,39.873 26.749,38.09 L 26.749,13.699 C 26.749,12.152 25.2,10.622 23.673,10.622 L 19.75,10.622" - id="path7" - style="fill:none;stroke:#ffffff;stroke-width:3.5;stroke-linejoin:round" /> - </g> -</g><g - id="g9" - transform="matrix(0.6976744,0,0,0.7277711,0,11.605042)"> - <g - id="g11"> - <path - d="M 19.75,31.859 C 19.75,33.502 18.548,34.723 16.673,35.478 L 1.75,41.168 L 1.75,10.623 L 16.673,2.935 C 18.664,2.583 19.75,3.686 19.75,5.469 L 19.75,31.859 z" - id="path13" - style="fill:none;stroke:#ffffff;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round" /> - </g> -</g><line - x1="5.5922894" - y1="39.528976" - x2="5.5922894" - y2="17.763372" - id="line15" - style="fill:none;stroke:#ffffff;stroke-width:2.70127702;stroke-linecap:round;stroke-linejoin:round" /></g><g - transform="matrix(0.5891883,0,0,0.6267357,6.6476984e-2,7.8435405)" - id="g17"> - - <line - style="fill:none;stroke:#ffffff;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round" - id="line19" - y2="11.828" - x2="32.536999" - y1="1.75" - x1="42.615002" /> - <polyline - style="fill:none;stroke:#ffffff;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round" - id="polyline21" - points="33.322,2.539 42.615,1.75 41.826,11.043 " /> -</g></g> -</svg>
\ No newline at end of file diff --git a/icons/journal-import.svg b/icons/journal-import.svg new file mode 100644 index 0000000..b84a374 --- /dev/null +++ b/icons/journal-import.svg @@ -0,0 +1,30 @@ +<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd' [ + <!ENTITY stroke_color "#010101"> + <!ENTITY fill_color "#FFFFFF"> +]> +<svg enable-background="new 0 0 55 55" height="55px" id="Layer_1" version="1.1" viewBox="0 0 55 55" width="55px" x="0px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" y="0px"> +<g display="block" id="document-save"> + <g> + <g> + <g> + <path d="M6.736,49.002 h24.52c2.225,0,3.439-1.447,3.439-3.441V18.281c0-1.73-1.732-3.441-3.439-3.441h-4.389" fill="&fill_color;" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5"/> + </g> + </g> + <g> + <g> + <path d="M26.867,38.592 c0,1.836-1.345,3.201-3.441,4.047l-16.69,6.363V14.84l16.69-8.599c2.228-0.394,3.441,0.84,3.441,2.834V38.592z" fill="&fill_color;" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5"/> + </g> + </g> + <path d="M9.424,42.607 c0,0-1.351-0.543-2.702-0.543s-2.703,0.543-2.703,0.543" fill="none" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25"/> + <path d="M9.424,32.006 c0,0-1.239-0.543-2.815-0.543c-1.577,0-2.59,0.543-2.59,0.543" fill="none" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25"/> + <path d="M9.424,21.678 c0,0-1.125-0.544-2.927-0.544c-1.802,0-2.478,0.544-2.478,0.544" fill="none" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25"/> + + <line fill="none" stroke="&stroke_color;" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25" x1="13.209" x2="13.209" y1="46.533" y2="11.505"/> + + <g> + <line fill="none" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" x1="41.17" x2="52.441" y1="16.188" y2="4.917"/> + <polyline fill="none" points=" 51.562,15.306 41.17,16.188 42.053,5.794 " stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5"/> + </g> + </g> +</g> +</svg>
\ No newline at end of file diff --git a/restore.py b/restore.py new file mode 100644 index 0000000..4c700cb --- /dev/null +++ b/restore.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python +# +# Author: Sascha Silbe <sascha-pgp@silbe.org> +# +# 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/>. +"""Restore. Activity to write back a Sugar Journal backup in JEB format. +""" + +import gettext +import logging +import os +import select +import sys +import tempfile +import time +import traceback +import zipfile + +import dbus +import gobject +import gtk + +from sugar.activity.widgets import StopButton +from sugar.activity import activity +import sugar.env +from sugar.graphics.toolbutton import ToolButton +from sugar.graphics.toolbarbox import ToolbarBox +import sugar.logger + +try: + import json +except ImportError: + import simplejson as json + + +DS_DBUS_SERVICE = "org.laptop.sugar.DataStore" +DS_DBUS_INTERFACE1 = "org.laptop.sugar.DataStore" +DS_DBUS_PATH1 = "/org/laptop/sugar/DataStore" +DS_DBUS_INTERFACE2 = "org.laptop.sugar.DataStore2" +DS_DBUS_PATH2 = "/org/laptop/sugar/DataStore2" + +CTIME_FORMAT = '%Y-%m-%dT%H:%M:%S' + + +def format_size(size): + if not size: + return _('Empty') + elif size < 10*1024: + return _('%4d B') % size + elif size < 10*1024**2: + return _('%4d KiB') % (size // 1024) + elif size < 10*1024**3: + return _('%4d MiB') % (size // 1024**2) + else: + return _('%4d GiB') % (size // 1024**3) + + +class MalformedBundleException(Exception): + """Trying to read an invalid bundle.""" + pass + + +class RestoreButton(ToolButton): + + def __init__(self, **kwargs): + ToolButton.__init__(self, 'journal-import', **kwargs) + self.props.tooltip = _('Restore Journal').encode('utf-8') + self.props.accelerator = '<Alt>r' + + +class AsyncRestore(gobject.GObject): + """ + Restore a backup to the Sugar data store 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, ([str])), + } + + def __init__(self, path): + gobject.GObject.__init__(self) + self._path = path + self._bundle = None + self._child_pid = None + self._pipe_from_child = None + self._pipe_to_child = None + self._pipe_from_child_watch_id = None + self._data_store = None + + + def start(self): + """Start the restore process.""" + to_child_read_fd, to_child_write_fd = os.pipe() + from_child_read_fd, from_child_write_fd = os.pipe() + + self._child_pid = os.fork() + if not self._child_pid: + os.close(from_child_read_fd) + os.close(to_child_write_fd) + self._pipe_from_child = os.fdopen(from_child_write_fd, 'w') + self._pipe_to_child = os.fdopen(to_child_read_fd, 'r') + self._child_run() + sys.exit(0) + else: + os.close(from_child_write_fd) + os.close(to_child_read_fd) + self._pipe_from_child = os.fdopen(from_child_read_fd, 'r') + self._pipe_to_child = os.fdopen(to_child_write_fd, 'w') + self._pipe_from_child_watch_id = gobject.io_add_watch( + self._pipe_from_child, + gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, + self._child_io_cb) + + def abort(self): + """Abort the restore.""" + self._pipe_to_child.write('abort\n') + self._parent_close() + + def _child_io_cb(self, source_, condition): + """Receive and handle message from child.""" + if condition in [gobject.IO_ERR, gobject.IO_HUP]: + logging.debug('error condition: %r', condition) + self.emit('error', + _('Lost connection to child process').encode('utf-8')) + self._parent_close() + return False + + status = self._read_line_from_child() + if status == 'progress': + position = int(self._read_line_from_child()) + num_entries = int(self._read_line_from_child()) + self.emit('progress', position, num_entries) + return True + + elif status == 'done': + self.emit('done') + self._parent_close() + return False + + elif status == 'error': + message = unicode(self._read_line_from_child(), 'utf-8') + trace = unicode(self._pipe_from_child.read().strip(), 'utf-8') + logging.error('Child reported error: %s\n%s', message, trace) + self.emit('error', message.encode('utf-8')) + self._parent_close() + return False + + else: + logging.error('Unknown status %r from child process', status) + self.emit('error', 'Unknown status %r from child process', status) + self.abort() + return False + + def _read_line_from_child(self): + """Read a line from the child process using low-level IO. + + This is a hack to work around the fact that file.readline() buffers + data without us knowing about it. If we call readline() a second + time when no data is buffered, it may block (=> the UI would hang). + If OTOH there is another line already in the buffer, we won't get + notified about it by select() as it already is in userspace. + There are cleaner ways to handle this (e.g. using the asyncore module), + but they are much more complex. + """ + line = [] + while True: + character = os.read(self._pipe_from_child.fileno(), 1) + if character == '\n': + return ''.join(line) + + line.append(character) + + def _parent_close(self): + """Close connections to child and wait for it.""" + gobject.source_remove(self._pipe_from_child_watch_id) + self._pipe_from_child.close() + self._pipe_to_child.close() + pid_, status = os.waitpid(self._child_pid, 0) + if os.WIFEXITED(status): + logging.debug('Child exited with rc=%d', os.WEXITSTATUS(status)) + elif os.WIFSIGNALED(status): + logging.debug('Child killed by signal %d', os.WTERMSIG(status)) + else: + logging.error('Sudden infant death syndrome') + + def _child_run(self): + """Main program of child.""" + try: + self._connect_to_data_store() + self._bundle = zipfile.ZipFile(self._path, 'r') + self._check_bundle() + + entries = self._get_directories().items() + num_entries = len(entries) + for position, (object_id, file_paths) in enumerate(entries): + self._client_check_command() + + if len(object_id) < 36: + logging.warning('Ignoring unknown directory %r', object_id) + continue + + if self._METADATA_JSON_NAME not in file_paths: + logging.warning('Ignoring directory %r without %s', + object_id, self._METADATA_JSON_NAME) + continue + + logging.debug('processing entry %r', object_id) + + try: + self._install_entry(object_id, file_paths) + # pylint: disable=W0703 + except Exception: + # TODO: relay to UI + logging.exception('Error installing Journal entry %r:', + object_id) + + self._send_to_parent('progress\n%d\n%d\n' % (position, + num_entries)) + + self._send_to_parent('progress\n%d\n%d\n' % (num_entries, + num_entries)) + self._close_bundle() + self._send_to_parent('done\n') + + # pylint: disable=W0703 + except Exception, exception: + self._pipe_from_child.write('error\n') + message = unicode(exception).encode('utf-8') + self._pipe_from_child.write(message+'\n') + trace = unicode(traceback.format_exc()).encode('utf-8') + self._pipe_from_child.write(trace) + self._close_bundle() + sys.exit(2) + + def _send_to_parent(self, message): + self._pipe_from_child.write(message) + self._pipe_from_child.flush() + + def _client_check_command(self): + """Check for and execute command from the parent.""" + in_ready, out_ready_, errors_on_ = select.select([self._pipe_to_child], + [], [], 0) + if not in_ready: + return + + command = self._pipe_to_child.readline().strip() + logging.debug('command %r received', command) + if command == 'abort': + self._remove_bundle() + sys.exit(1) + else: + raise ValueError('Unknown command %r' % (command, )) + + def _check_bundle(self): + """Check bundle for validity.""" + # potentially expensive, but avoids trouble during unpacking + if self._bundle.testzip() is not None: + raise MalformedBundleException(_('Corrupt zip file')) + + file_names = self._bundle.namelist() + if not file_names: + raise MalformedBundleException(_('Empty bundle')) + + metadata_seen = False + for name in file_names: + for part in name.split('/'): + if part.startswith('.'): + raise MalformedBundleException( + _('Path component starts with dot: %r') % (name, )) + + if name.split('/')[-1] == self._METADATA_JSON_NAME: + metadata_seen = True + + if not metadata_seen: + raise MalformedBundleException('No metadata file found') + + def _read_data(self, object_id): + """Read data for given object from bundle.""" + # TODO: verify this uses the activity data dir + data_fd, data_file_name = tempfile.mkstemp(prefix='Restore') + data_file = os.fdopen(data_fd, 'w') + try: + # TODO: handle large files better (i.e. use external tool) + # TODO: predict disk-full + data_file.write(self._bundle.read( + os.path.join(object_id, object_id))) + return data_file_name + finally: + data_file.close() + + def _read_metadata(self, object_id): + """Read metadata for given object from bundle.""" + metadata_path = os.path.join(object_id, self._METADATA_JSON_NAME) + json_data = self._bundle.read(metadata_path) + return json.loads(json_data) + + def _get_directories(self): + """Get the names of top-level directories in bundle and of their files. + """ + contents = {} + for path in self._bundle.namelist(): + if path.endswith('/'): + continue + + directory, file_name = path.lstrip('/').split('/', 1) + contents.setdefault(directory, []).append(file_name) + + return contents + + def _install_entry(self, object_id, file_paths): + """Reassemble the given entry and save it to the data store. + + file_paths is destroyed as a side effect.""" + file_paths.remove(self._METADATA_JSON_NAME) + metadata = self._read_metadata(object_id) + + data_file_name = '' + if object_id in file_paths: + file_paths.remove(object_id) + data_file_name = self._read_data(object_id) + + for path in file_paths: + components = path.split('/') + if len(components) != 2 or components[1] != object_id: + logging.warning('Ignoring unknown file %r', path) + + name = components[0] + value = self._bundle.read(os.path.join(object_id, path)) + metadata[name] = dbus.ByteArray(value) + + del file_paths[:] + self._save_entry(metadata, data_file_name) + + def _close_bundle(self): + """Ensure the bundle is closed.""" + if self._bundle and self._bundle.fp and not self._bundle.fp.closed: + self._bundle.close() + + def _connect_to_data_store(self): + """Open a connection to a Sugar data store.""" + # We forked => need to use a private connection and make sure we + # never allow the main loop to run + # http://lists.freedesktop.org/archives/dbus/2007-April/007498.html + # http://lists.freedesktop.org/archives/dbus/2007-August/008359.html + bus = dbus.SessionBus(private=True) + try: + self._data_store = dbus.Interface(bus.get_object(DS_DBUS_SERVICE, + DS_DBUS_PATH2), DS_DBUS_INTERFACE2) + self._data_store.find({'tree_id': 'invalid'}, + {'metadata': ['tree_id']}) + logging.info('Data store with version support found') + return + + except dbus.DBusException: + logging.debug('No data store with version support found') + + self._data_store = dbus.Interface(bus.get_object(DS_DBUS_SERVICE, + DS_DBUS_PATH1), DS_DBUS_INTERFACE1) + self._data_store.find({'uid': 'invalid'}, ['uid']) + logging.info('Data store without version support found') + + def _save_entry(self, metadata, data_path): + """Store object in data store.""" + if self._data_store.dbus_interface == DS_DBUS_INTERFACE2: + tree_id = metadata.get('tree_id') or metadata['uid'] + version_id = metadata.get('version_id', '') + parent_id = metadata.get('parent_id', '') + if self._find_entry_v2(tree_id, version_id): + logging.info('Skipping existing entry %r / %r', tree_id, + version_id) + return + + # FIXME: cannot restore version_id + self._data_store.save(tree_id, parent_id, metadata, data_path, + True) + else: + uid = metadata.get('uid') or metadata['tree_id'] + timestamp = metadata.get('timestamp') or \ + time.strftime(CTIME_FORMAT, metadata['ctime']) + entry = self._find_entry_v1(uid) + if entry: + ds_timestamp = entry.get('timestamp') or \ + time.strftime(CTIME_FORMAT, entry[0]['ctime']) + + if ds_timestamp >= timestamp: + logging.info('Skipping outdated entry for %r', uid) + return + + self._data_store.update(uid, metadata, data_path, True) + + else: + self._data_store.create(metadata, data_path, True) + + def _find_entry_v1(self, uid): + """Retrieve given entry from v1 data store if it exists. + """ + try: + return self._data_store.get_properties(uid) + + except dbus.DBusException, exception: + exception_name = exception.get_dbus_name() + if exception_name.startswith('org.freedesktop.DBus.Python'): + return None + + raise + + def _find_entry_v2(self, tree_id, version_id): + """Retrieve given entry from v2 data store if it exists. + """ + query = {'tree_id': tree_id} + if version_id: + query['version_id'] = version_id + + entries = self._data_store.find(query,{}, byte_arrays=True)[0] + if entries: + return entries[0] + return None + + +class RestoreActivity(activity.Activity): + + def __init__(self, handle): + activity.Activity.__init__(self, handle, create_jobject=False) + self.max_participants = 1 + self._progress_bar = None + self._message_box = None + self._restore = None + self._restore_button = None + self._no_bundle_warning = None + self._path = None + self._setup_widgets() + + def read_file(self, file_path): + """Set path to bundle to restore.""" + self._path = file_path + self._no_bundle_warning.hide() + self._restore_button.set_sensitive(True) + + def write_file(self, file_path): + """We don't have any state to save in the Journal.""" + return + + def save(self): + """We don't have any state to save in the Journal.""" + return + + def close(self, skip_save=False): + """We don't have any state to save in the Journal.""" + activity.Activity.close(self, skip_save=True) + + def _setup_widgets(self): + self._setup_toolbar() + self._setup_main_view() + + def _setup_main_view(self): + vbox = gtk.VBox() + warning = _('No bundle selected. Please close this activity and' + ' choose a bundle to restore from the Journal.') + self._no_bundle_warning = gtk.Label(warning.encode('utf-8')) + vbox.pack_start(self._no_bundle_warning, True) + self.set_canvas(vbox) + vbox.show_all() + + def _setup_toolbar(self): + toolbar_box = ToolbarBox() + + self._restore_button = RestoreButton() + self._restore_button.connect('clicked', self._restore_cb) + self._restore_button.set_sensitive(False) + toolbar_box.toolbar.insert(self._restore_button, -1) + + separator = gtk.SeparatorToolItem() + separator.props.draw = False + separator.set_expand(True) + toolbar_box.toolbar.insert(separator, -1) + + stop_button = StopButton(self) + toolbar_box.toolbar.insert(stop_button, -1) + + self.set_toolbar_box(toolbar_box) + toolbar_box.show_all() + + def _restore_cb(self, button): + """Callback for Restore button.""" + self._setup_restore_view() + self._start_restore() + + def _start_restore(self): + """Set up and start background worker process.""" + self._restore = AsyncRestore(self._path) + self._restore.connect('progress', self._progress_cb) + self._restore.connect('error', self._error_cb) + self._restore.connect('done', self._done_cb) + self._restore.start() + + def _setup_restore_view(self): + """Set up UI for showing feedback from worker process.""" + self._restore_button.set_sensitive(False) + vbox = gtk.VBox(False) + + label_text = _('Restoring Journal from %s') % (self._path, ) + label = gtk.Label(label_text.encode('utf-8')) + 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.props.text = _('Scanning bundle').encode('utf-8') + self._progress_bar.show() + alignment.add(self._progress_bar) + vbox.add(alignment) + + self._message_box = gtk.Label() + vbox.pack_start(self._message_box) + + # FIXME +# 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() + + def _progress_cb(self, restore_, position, num_entries): + """Update progress bar with information from child process.""" + self._progress_bar.props.text = '%d / %d' % (position, num_entries) + self._progress_bar.props.fraction = float(position) / num_entries + + def _done_cb(self, restore_): + """Restore finished.""" + logging.debug('_done_cb') + self._restore_button.set_sensitive(True) +# self._close_button.set_label(_('Finish')) + + def _error_cb(self, restore_, message): + """Receive error message from child process.""" + self._show_error(unicode(message, 'utf-8')) + self._restore_button.set_sensitive(True) + + def _show_error(self, message): + """Present error message to user.""" + self._message_box.props.label = unicode(message).encode('utf-8') + self._message_box.show() + +# def _close_cb(self, button): +# if not self._done: +# self._restore.abort() + +# self.emit('close') + + +# pylint isn't smart enough for the gettext.install() magic +_ = lambda msg: msg +gettext.install('restore', 'po', unicode=True) +sugar.logger.start() |