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-30 09:24:13 (GMT) |
commit | ca84b33b024c8ead02efac821cb7f5308fa97e76 (patch) | |
tree | 24ad621248b4a88cbac3271134647fe12d19a5c9 | |
parent | 5ec2baafba4dd31fe571e7598134ac10997482d2 (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-- | icons/journal-export.svg | 100 | ||||
-rw-r--r-- | icons/journal-import.svg | 30 | ||||
-rw-r--r-- | restore.py | 585 |
7 files changed, 289 insertions, 577 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/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 @@ -13,14 +13,13 @@ # # 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. +"""Restore. Activity to write back a Sugar Journal backup in JEB format. """ import gettext import logging import os import select -import statvfs import sys import tempfile import time @@ -28,21 +27,15 @@ 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 @@ -56,11 +49,7 @@ 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' +CTIME_FORMAT = '%Y-%m-%dT%H:%M:%S' def format_size(size): @@ -76,17 +65,22 @@ def format_size(size): return _('%4d GiB') % (size // 1024**3) -class BackupButton(ToolButton): +class MalformedBundleException(Exception): + """Trying to read an invalid bundle.""" + pass + + +class RestoreButton(ToolButton): def __init__(self, **kwargs): - ToolButton.__init__(self, 'journal-export', **kwargs) - self.props.tooltip = _('Backup Journal').encode('utf-8') - self.props.accelerator = '<Alt>b' + ToolButton.__init__(self, 'journal-import', **kwargs) + self.props.tooltip = _('Restore Journal').encode('utf-8') + self.props.accelerator = '<Alt>r' -class AsyncBackup(gobject.GObject): +class AsyncRestore(gobject.GObject): """ - Run a data store backup asynchronously. + Restore a backup to the Sugar data store asynchronously. """ _METADATA_JSON_NAME = '_metadata.json' @@ -98,27 +92,19 @@ class AsyncBackup(gobject.GObject): 'error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([str])), } - def __init__(self, mount_point): + def __init__(self, path): gobject.GObject.__init__(self) - self._mount_point = mount_point - self._path = None + 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._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.""" + """Start the restore process.""" to_child_read_fd, to_child_write_fd = os.pipe() from_child_read_fd, from_child_write_fd = os.pipe() @@ -141,7 +127,7 @@ class AsyncBackup(gobject.GObject): self._child_io_cb) def abort(self): - """Abort the backup and clean up.""" + """Abort the restore.""" self._pipe_to_child.write('abort\n') self._parent_close() @@ -216,22 +202,39 @@ class AsyncBackup(gobject.GObject): """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() + self._bundle = zipfile.ZipFile(self._path, 'r') + self._check_bundle() - for position, entry in enumerate(self._entries): + 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, - self._num_entries)) - logging.debug('processing entry %r', entry) - self._add_entry(self._bundle, entry) + num_entries)) - 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('progress\n%d\n%d\n' % (num_entries, + num_entries)) + self._close_bundle() self._send_to_parent('done\n') # pylint: disable=W0703 @@ -241,7 +244,7 @@ class AsyncBackup(gobject.GObject): self._pipe_from_child.write(message+'\n') trace = unicode(traceback.format_exc()).encode('utf-8') self._pipe_from_child.write(trace) - self._remove_bundle() + self._close_bundle() sys.exit(2) def _send_to_parent(self, message): @@ -263,72 +266,90 @@ class AsyncBackup(gobject.GObject): 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') + 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: - return path, zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) + # 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: - os.close(bundle_fd) + data_file.close() - def _remove_bundle(self): - """Close bundle and remove it from permanent storage.""" - if self._path: - os.remove(self._path) + 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) - if self._bundle and self._bundle.fp and not self._bundle.fp.closed: - self._bundle.close() + 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 - def _create_file(self, directory, prefix, suffix): - """Create a unique file with given prefix and suffix in directory. + directory, file_name = path.lstrip('/').split('/', 1) + contents.setdefault(directory, []).append(file_name) - 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 + return contents - except OSError: - return tempfile.mkstemp(dir=directory, prefix=prefix + ' ', - suffix=suffix) + def _install_entry(self, object_id, file_paths): + """Reassemble the given entry and save it to the data store. - 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)) + 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.""" @@ -353,63 +374,88 @@ class AsyncBackup(gobject.GObject): 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.""" + def _save_entry(self, metadata, data_path): + """Store object in 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) + 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 - 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] + # FIXME: cannot restore version_id + self._data_store.save(tree_id, parent_id, metadata, data_path, + True) else: - return self._data_store.get_properties(object_id, byte_arrays=True) + 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 - 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) + 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) -class BackupActivity(activity.Activity): + 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 - _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 + +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._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._restore = None + self._restore_button = None + self._no_bundle_warning = None + self._path = None self._setup_widgets() - self._find_media() 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 write_file(self, file_path): + def save(self): """We don't have any state to save in the Journal.""" return @@ -423,45 +469,20 @@ class BackupActivity(activity.Activity): 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() + vbox.show_all() 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) + 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 @@ -474,29 +495,25 @@ class BackupActivity(activity.Activity): 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 _restore_cb(self, button): + """Callback for Restore button.""" + self._setup_restore_view() + self._start_restore() - def _start_backup(self, mount_point): + def _start_restore(self): """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() + 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_backup_view(self, mount_point): + 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 = _('Backing up Journal to %s') % (mount_point, ) + label_text = _('Restoring Journal from %s') % (self._path, ) label = gtk.Label(label_text.encode('utf-8')) label.show() vbox.pack_start(label) @@ -505,7 +522,7 @@ class BackupActivity(activity.Activity): alignment.show() self._progress_bar = gtk.ProgressBar() - self._progress_bar.props.text = _('Scanning Journal').encode('utf-8') + self._progress_bar.props.text = _('Scanning bundle').encode('utf-8') self._progress_bar.show() alignment.add(self._progress_bar) vbox.add(alignment) @@ -526,19 +543,21 @@ class BackupActivity(activity.Activity): self.set_canvas(vbox) self.show() - def _progress_cb(self, backup, position, num_entries): + 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, backup): - """Backup finished.""" + 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, backup, message): + 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.""" @@ -547,184 +566,12 @@ class BackupActivity(activity.Activity): # def _close_cb(self, button): # if not self._done: -# self._backup.abort() +# self._restore.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) +gettext.install('restore', 'po', unicode=True) sugar.logger.start() |