diff options
author | Ajay Garg <ajay@activitycentral.com> | 2012-10-12 12:33:02 (GMT) |
---|---|---|
committer | Ajay Garg <ajay@activitycentral.com> | 2012-10-15 15:46:37 (GMT) |
commit | b16993db1deaf85793dd950159c4379067277992 (patch) | |
tree | 8e90e30b22162d52d4670263235daccb738ea458 | |
parent | b436a2049cdbf6fa9cd4aead9e33b727607aa51d (diff) |
Multi-Select; 1-to-N.
Signed-off-by: Ajay Garg <ajay@activitycentral.com>
-rw-r--r-- | src/jarabe/journal/Makefile.am | 3 | ||||
-rw-r--r-- | src/jarabe/journal/expandedentry.py | 39 | ||||
-rw-r--r-- | src/jarabe/journal/journalactivity.py | 181 | ||||
-rw-r--r-- | src/jarabe/journal/journaltoolbox.py | 336 | ||||
-rw-r--r-- | src/jarabe/journal/journalwindow.py | 45 | ||||
-rw-r--r-- | src/jarabe/journal/keepicon.py | 11 | ||||
-rw-r--r-- | src/jarabe/journal/listmodel.py | 23 | ||||
-rw-r--r-- | src/jarabe/journal/listview.py | 256 | ||||
-rw-r--r-- | src/jarabe/journal/misc.py | 34 | ||||
-rw-r--r-- | src/jarabe/journal/model.py | 482 | ||||
-rw-r--r-- | src/jarabe/journal/palettes.py | 1024 | ||||
-rw-r--r-- | src/jarabe/journal/volumestoolbar.py | 184 | ||||
-rw-r--r-- | src/jarabe/journal/webdavmanager.py | 312 | ||||
-rw-r--r-- | src/jarabe/model/neighborhood.py | 3 | ||||
-rw-r--r-- | src/jarabe/view/buddymenu.py | 12 | ||||
-rw-r--r-- | src/jarabe/view/palettes.py | 51 |
16 files changed, 2780 insertions, 216 deletions
diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am index ba29062..98effcf 100644 --- a/src/jarabe/journal/Makefile.am +++ b/src/jarabe/journal/Makefile.am @@ -15,4 +15,5 @@ sugar_PYTHON = \ model.py \ objectchooser.py \ palettes.py \ - volumestoolbar.py + volumestoolbar.py \ + webdavmanager.py diff --git a/src/jarabe/journal/expandedentry.py b/src/jarabe/journal/expandedentry.py index 8144980..f26e71f 100644 --- a/src/jarabe/journal/expandedentry.py +++ b/src/jarabe/journal/expandedentry.py @@ -160,11 +160,30 @@ class ExpandedEntry(Gtk.EventBox): self._buddy_list.pack_start(self._create_buddy_list(), False, False, style.DEFAULT_SPACING) - description = metadata.get('description', '') + # TRANS: Do not translate the """%s""". + uploader_nick_text = self.__create_text_description( + _('Source XO Nick :: \n%s'), metadata.get('uploader-nick', '')) + + # TRANS: Do not translate the """%s""". + uploader_serial_text = self.__create_text_description( + _('Source XO Serial Number :: \n%s'), metadata.get('uploader-serial', '')) + + # TRANS: Do not translate the """%s""". + misc_info_text = self.__create_text_description( + _('Misellaneous Information :: \n%s'), metadata.get('description', '')) + + description = uploader_nick_text + uploader_serial_text + misc_info_text self._description.get_buffer().set_text(description) + tags = metadata.get('tags', '') self._tags.get_buffer().set_text(tags) + def __create_text_description(self, heading, value): + if (value == '') or (value is None): + return '' + + return ((heading % value) + '\n\n') + def _create_keep_icon(self): keep_icon = KeepIcon() keep_icon.connect('toggled', self._keep_icon_toggled_cb) @@ -396,18 +415,20 @@ class ExpandedEntry(Gtk.EventBox): needs_update = True if needs_update: - if self._metadata.get('mountpoint', '/') == '/': - model.write(self._metadata, update_mtime=False) - else: - old_file_path = os.path.join(self._metadata['mountpoint'], - model.get_file_name(old_title, - self._metadata['mime_type'])) - model.write(self._metadata, file_path=old_file_path, - update_mtime=False) + from jarabe.journal.journalactivity import get_journal + self._metadata['mountpoint'] = \ + get_journal().get_detail_toolbox().get_mount_point() + + model.update_only_metadata_and_preview_files_and_return_file_paths(self._metadata) self._update_title_sid = None def _keep_icon_toggled_cb(self, keep_icon): + # If it is a locally-mounted remote-share, return without doing + # any processing. + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if keep_icon.get_active(): self._metadata['keep'] = 1 else: diff --git a/src/jarabe/journal/journalactivity.py b/src/jarabe/journal/journalactivity.py index 4bb68fd..b2ffae0 100644 --- a/src/jarabe/journal/journalactivity.py +++ b/src/jarabe/journal/journalactivity.py @@ -19,6 +19,7 @@ import logging from gettext import gettext as _ import uuid +from gi.repository import GObject from gi.repository import Gtk from gi.repository import Gdk from gi.repository import GdkX11 @@ -27,7 +28,8 @@ import statvfs import os from sugar3.graphics.window import Window -from sugar3.graphics.alert import ErrorAlert +from sugar3.graphics.icon import Icon +from sugar3.graphics.alert import Alert, ErrorAlert, ConfirmationAlert from sugar3.bundle.bundle import ZipExtractException, RegistrationException from sugar3 import env @@ -37,7 +39,9 @@ from gi.repository import SugarExt from jarabe.model import bundleregistry from jarabe.journal.journaltoolbox import MainToolbox, DetailToolbox +from jarabe.journal.journaltoolbox import EditToolbox from jarabe.journal.listview import ListView +from jarabe.journal.listmodel import ListModel from jarabe.journal.detailview import DetailView from jarabe.journal.volumestoolbar import VolumesToolbar from jarabe.journal import misc @@ -46,6 +50,7 @@ from jarabe.journal.objectchooser import ObjectChooser from jarabe.journal.modalalert import ModalAlert from jarabe.journal import model from jarabe.journal.journalwindow import JournalWindow +from jarabe.journal.journalwindow import show_normal_cursor J_DBUS_SERVICE = 'org.laptop.Journal' @@ -56,6 +61,7 @@ _SPACE_TRESHOLD = 52428800 _BUNDLE_ID = 'org.laptop.JournalActivity' _journal = None +_mount_point = None class JournalActivityDBusService(dbus.service.Object): @@ -124,8 +130,33 @@ class JournalActivity(JournalWindow): self._list_view = None self._detail_view = None self._main_toolbox = None + self._edit_toolbox = None self._detail_toolbox = None self._volumes_toolbar = None + self._editing_mode = False + self._alert = Alert() + + self._error_alert = Alert() + icon = Icon(icon_name='dialog-ok') + self._error_alert.add_button(Gtk.ResponseType.OK, _('Ok'), icon) + icon.show() + + self._confirmation_alert = Alert() + icon = Icon(icon_name='dialog-cancel') + self._confirmation_alert.add_button(Gtk.ResponseType.CANCEL, _('Stop'), icon) + icon.show() + icon = Icon(icon_name='dialog-ok') + self._confirmation_alert.add_button(Gtk.ResponseType.OK, _('Continue'), icon) + icon.show() + + self._current_alert = None + self.setup_handlers_for_alert_actions() + + self._info_alert = None + self._selected_entries = [] + self._bundle_installation_allowed = True + + set_mount_point('/') self._setup_main_view() self._setup_secondary_view() @@ -151,10 +182,17 @@ class JournalActivity(JournalWindow): self._check_available_space() def __volume_error_cb(self, gobject, message, severity): - alert = ErrorAlert(title=severity, msg=message) - alert.connect('response', self.__alert_response_cb) - self.add_alert(alert) - alert.show() + self.update_title_and_message(self._error_alert, severity, + message) + self._callback = None + self._data = None + self.update_alert(self._error_alert) + + def _show_alert(self, message, severity): + self.__volume_error_cb(None, message, severity) + + def _volume_error_cb(self, gobject, message, severity): + self.update_error_alert(severity, message, None, None) def __alert_response_cb(self, alert, response_id): self.remove_alert(alert) @@ -196,11 +234,14 @@ class JournalActivity(JournalWindow): self._main_toolbox.search_entry.connect('icon-press', self.__search_icon_pressed_cb) self._main_toolbox.set_mount_point('/') + #search_toolbar.set_mount_point('/') + set_mount_point('/') def _setup_secondary_view(self): self._secondary_view = Gtk.VBox() self._detail_toolbox = DetailToolbox() + self._detail_toolbox.set_mount_point('/') self._detail_toolbox.connect('volume-error', self.__volume_error_cb) @@ -240,9 +281,16 @@ class JournalActivity(JournalWindow): self.connect('key-press-event', self._key_press_event_cb) def show_main_view(self): - if self.toolbar_box != self._main_toolbox: - self.set_toolbar_box(self._main_toolbox) - self._main_toolbox.show() + if self._editing_mode: + self._toolbox = EditToolbox() + + # TRANS: Do not translate the "%d" + self._toolbox.set_total_number_of_entries(self.get_total_number_of_entries()) + else: + self._toolbox = self._main_toolbox + + self.set_toolbar_box(self._toolbox) + self._toolbox.show() if self.canvas != self._main_view: self.set_canvas(self._main_view) @@ -277,6 +325,10 @@ class JournalActivity(JournalWindow): def __volume_changed_cb(self, volume_toolbar, mount_point): logging.debug('Selected volume: %r.', mount_point) self._main_toolbox.set_mount_point(mount_point) + set_mount_point(mount_point) + + # Also, need to update the mount-point for Detail-View. + self._detail_toolbox.set_mount_point(mount_point) def __model_created_cb(self, sender, **kwargs): self._check_for_bundle(kwargs['object_id']) @@ -301,6 +353,9 @@ class JournalActivity(JournalWindow): self._list_view.update_dates() def _check_for_bundle(self, object_id): + if not self._bundle_installation_allowed: + return + registry = bundleregistry.get_registry() metadata = model.get(object_id) @@ -336,6 +391,9 @@ class JournalActivity(JournalWindow): metadata['bundle_id'] = bundle.get_bundle_id() model.write(metadata) + def set_bundle_installation_allowed(self, allowed): + self._bundle_installation_allowed = allowed + def __window_state_event_cb(self, window, event): logging.debug('window_state_event_cb %r', self) if event.changed_mask & Gdk.WindowState.ICONIFIED: @@ -378,6 +436,105 @@ class JournalActivity(JournalWindow): self.reveal() self.show_main_view() + def switch_to_editing_mode(self, switch): + # (re)-switch, only if not already. + if (switch) and (not self._editing_mode): + self._editing_mode = True + self.get_list_view().disable_drag_and_copy() + self.show_main_view() + elif (not switch) and (self._editing_mode): + self._editing_mode = False + self.get_list_view().enable_drag_and_copy() + self.show_main_view() + + def get_list_view(self): + return self._list_view + + def setup_handlers_for_alert_actions(self): + self._error_alert.connect('response', + self.__check_for_alert_action) + self._confirmation_alert.connect('response', + self.__check_for_alert_action) + + def __check_for_alert_action(self, alert, response_id): + self.hide_alert() + if self._callback is not None: + GObject.idle_add(self._callback, self._data, + response_id) + + def update_title_and_message(self, alert, title, message): + alert.props.title = title + alert.props.msg = message + + def update_alert(self, alert): + if self._current_alert is None: + self.add_alert(alert) + elif self._current_alert != alert: + self.remove_alert(self._current_alert) + self.add_alert(alert) + + self.remove_alert(self._current_alert) + self.add_alert(alert) + self._current_alert = alert + self._current_alert.show() + show_normal_cursor() + + def hide_alert(self): + if self._current_alert is not None: + self._current_alert.hide() + + def update_info_alert(self, title, message): + self.get_toolbar_box().display_running_status_in_multi_select(title, message) + + def update_error_alert(self, title, message, callback, data): + self.update_title_and_message(self._error_alert, title, + message) + self._callback = callback + self._data = data + self.update_alert(self._error_alert) + + def update_confirmation_alert(self, title, message, callback, + data): + self.update_title_and_message(self._confirmation_alert, title, + message) + self._callback = callback + self._data = data + self.update_alert(self._confirmation_alert) + + def update_progress(self, fraction): + self.get_toolbar_box().update_progress(fraction) + + def get_metadata_list(self, selected_state): + metadata_list = [] + + list_view_model = self.get_list_view().get_model() + for index in range(0, len(list_view_model)): + metadata = list_view_model.get_metadata(index) + metadata_selected = \ + list_view_model.get_selected_value(metadata['uid']) + + if ( (selected_state and metadata_selected) or \ + ((not selected_state) and (not metadata_selected)) ): + metadata_list.append(metadata) + + return metadata_list + + def get_total_number_of_entries(self): + list_view_model = self.get_list_view().get_model() + return len(list_view_model) + + def is_editing_mode_present(self): + return self._editing_mode + + def get_volumes_toolbar(self): + return self._volumes_toolbar + + def get_toolbar_box(self): + return self._toolbox + + def get_detail_toolbox(self): + return self._detail_toolbox + def get_journal(): global _journal @@ -389,3 +546,11 @@ def get_journal(): def start(): get_journal() + + +def set_mount_point(mount_point): + global _mount_point + _mount_point = mount_point + +def get_mount_point(): + return _mount_point diff --git a/src/jarabe/journal/journaltoolbox.py b/src/jarabe/journal/journaltoolbox.py index 09d8a31..cb19f65 100644 --- a/src/jarabe/journal/journaltoolbox.py +++ b/src/jarabe/journal/journaltoolbox.py @@ -16,6 +16,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA from gettext import gettext as _ +from gettext import ngettext import logging from datetime import datetime, timedelta import os @@ -26,6 +27,7 @@ from gi.repository import GObject from gi.repository import Gio import glib from gi.repository import Gtk +from gi.repository import Gdk from sugar3.graphics.palette import Palette from sugar3.graphics.toolbarbox import ToolbarBox @@ -45,8 +47,9 @@ from jarabe.journal import misc from jarabe.journal import model from jarabe.journal.palettes import ClipboardMenu from jarabe.journal.palettes import VolumeMenu -from jarabe.journal import journalwindow +from jarabe.journal import journalwindow, palettes +COPY_MENU_HELPER = palettes.get_copy_menu_helper() _AUTOSEARCH_TIMEOUT = 1000 @@ -74,6 +77,10 @@ class MainToolbox(ToolbarBox): def __init__(self): ToolbarBox.__init__(self) + self._info_widget = MultiSelectEntriesInfoWidget() + self.add(self._info_widget) + self._info_widget.hide() + self._mount_point = None self.search_entry = iconentry.IconEntry() @@ -123,6 +130,12 @@ class MainToolbox(ToolbarBox): self.refresh_filters() + def update_progress(self, fraction): + self._info_widget.update_progress(fraction) + + def hide_info_widget(self): + self._info_widget.hide() + def _get_when_search_combo(self): when_search = ComboBox() when_search.append_item(_ACTION_ANYTIME, _('Anytime')) @@ -390,11 +403,30 @@ class DetailToolbox(ToolbarBox): separator.show() erase_button = ToolButton('list-remove') + self._erase_button = erase_button erase_button.set_tooltip(_('Erase')) erase_button.connect('clicked', self._erase_button_clicked_cb) self.toolbar.insert(erase_button, -1) erase_button.show() + def set_mount_point(self, mount_point): + self._mount_point = mount_point + self.set_sensitivity_of_icons() + + def get_mount_point(self): + return self._mount_point + + def set_sensitivity_of_icons(self): + mount_point = self.get_mount_point() + if model.is_mount_point_for_locally_mounted_remote_share(mount_point): + sensitivity = False + else: + sensitivity = True + + self._resume.set_sensitive(sensitivity) + self._duplicate.set_sensitive(sensitivity) + self._erase_button.set_sensitive(sensitivity) + def set_metadata(self, metadata): self._metadata = metadata self._refresh_copy_palette() @@ -452,39 +484,11 @@ class DetailToolbox(ToolbarBox): palette.menu.remove(menu_item) menu_item.destroy() - clipboard_menu = ClipboardMenu(self._metadata) - clipboard_menu.set_image(Icon(icon_name='toolbar-edit', - icon_size=Gtk.IconSize.MENU)) - clipboard_menu.connect('volume-error', self.__volume_error_cb) - palette.menu.append(clipboard_menu) - clipboard_menu.show() - - if self._metadata['mountpoint'] != '/': - client = GConf.Client.get_default() - color = XoColor(client.get_string('/desktop/sugar/user/color')) - journal_menu = VolumeMenu(self._metadata, _('Journal'), '/') - journal_menu.set_image(Icon(icon_name='activity-journal', - xo_color=color, - icon_size=Gtk.IconSize.MENU)) - journal_menu.connect('volume-error', self.__volume_error_cb) - palette.menu.append(journal_menu) - journal_menu.show() - - volume_monitor = Gio.VolumeMonitor.get() - icon_theme = Gtk.IconTheme.get_default() - for mount in volume_monitor.get_mounts(): - if self._metadata['mountpoint'] == mount.get_root().get_path(): - continue - volume_menu = VolumeMenu(self._metadata, mount.get_name(), - mount.get_root().get_path()) - for name in mount.get_icon().props.names: - if icon_theme.has_icon(name): - volume_menu.set_image(Icon(icon_name=name, - icon_size=Gtk.IconSize.MENU)) - break - volume_menu.connect('volume-error', self.__volume_error_cb) - palette.menu.append(volume_menu) - volume_menu.show() + COPY_MENU_HELPER.insert_copy_to_menu_items(palette.menu, + [self._metadata], + show_editing_alert=False, + show_progress_info_alert=False, + batch_mode=False) def _refresh_duplicate_palette(self): color = misc.get_icon_color(self._metadata) @@ -524,6 +528,270 @@ class DetailToolbox(ToolbarBox): menu_item.show() +class EditToolbox(ToolbarBox): + def __init__(self): + ToolbarBox.__init__(self) + + self.toolbar.add(SelectNoneButton()) + self.toolbar.add(SelectAllButton()) + + self.toolbar.add(Gtk.SeparatorToolItem()) + + self.toolbar.add(BatchEraseButton()) + self.toolbar.add(BatchCopyButton()) + + self.toolbar.add(Gtk.SeparatorToolItem()) + + self._multi_select_info_widget = MultiSelectEntriesInfoWidget() + self.toolbar.add(self._multi_select_info_widget) + + self.show_all() + self.toolbar.show_all() + + def process_new_selected_entry_in_multi_select(self): + GObject.idle_add(self._multi_select_info_widget.update_text, + '', '', True, True) + + def process_new_deselected_entry_in_multi_select(self): + GObject.idle_add(self._multi_select_info_widget.update_text, + '', '', False, True) + + def display_running_status_in_multi_select(self, primary_info, + secondary_info): + GObject.idle_add(self._multi_select_info_widget.update_text, + primary_info, secondary_info, + None, None) + + def display_already_selected_entries_status(self): + GObject.idle_add(self._multi_select_info_widget.update_text, + '', '', True, False) + + def set_total_number_of_entries(self, total): + self._multi_select_info_widget.set_total_number_of_entries(total) + + def get_current_entry_number(self): + return self._multi_select_info_widget.get_current_entry_number() + + def update_progress(self, fraction): + self._multi_select_info_widget.update_progress(fraction) + + +class SelectNoneButton(ToolButton): + def __init__(self): + ToolButton.__init__(self, 'select-none') + self.props.tooltip = _('Deselect all') + + self.connect('clicked', self.__do_deselect_all) + + def __do_deselect_all(self, widget_clicked): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.get_list_view()._selected_entries = 0 + journal.switch_to_editing_mode(False) + journal.get_list_view().inhibit_refresh(False) + journal.get_list_view().refresh() + + +class SelectAllButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'select-all') + palettes.ActionItem.__init__(self, '', [], + show_editing_alert=False, + show_progress_info_alert=False, + batch_mode=True, + auto_deselect_source_entries=True, + need_to_popup_options=False, + operate_on_deselected_entries=True, + show_not_completed_ops_info=False) + self.props.tooltip = _('Select all') + + def _get_actionable_signal(self): + return 'clicked' + + def _get_editing_alert_operation(self): + return _('Select all') + + def _get_info_alert_title(self): + return _('Selecting') + + def _get_post_selection_alert_message_entries_len(self): + return self._model_len + + def _get_post_selection_alert_message(self, entries_len): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + return ngettext('You have selected %d entry.', + 'You have selected %d entries.', + entries_len) % (entries_len,) + + def _operate(self, metadata): + # Nothing specific needs to be done. + # The checkboxes are unchecked as part of the toggling of any + # operation that operates on selected entries. + + # This is sync-operation. Thus, call the callback. + self._post_operate_per_metadata_per_action(metadata) + + +class BatchEraseButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'edit-delete') + palettes.ActionItem.__init__(self, '', [], + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True, + auto_deselect_source_entries=True, + need_to_popup_options=False, + operate_on_deselected_entries=False, + show_not_completed_ops_info=True) + self.props.tooltip = _('Erase') + + # De-sensitize Batch-Erase button, for locally-mounted-remote-shares. + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + self.set_sensitive(False) + + def _get_actionable_signal(self): + return 'clicked' + + def _get_editing_alert_title(self): + return _('Erase') + + def _get_editing_alert_message(self, entries_len): + return ngettext('Do you want to erase %d entry?', + 'Do you want to erase %d entries?', + entries_len) % (entries_len) + + def _get_editing_alert_operation(self): + return _('Erase') + + def _get_info_alert_title(self): + return _('Erasing') + + def _operate(self, metadata): + model.delete(metadata['uid']) + + # This is sync-operation. Thus, call the callback. + self._post_operate_per_metadata_per_action(metadata) + + +class BatchCopyButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'edit-copy') + palettes.ActionItem.__init__(self, '', [], + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True, + auto_deselect_source_entries=False, + need_to_popup_options=True, + operate_on_deselected_entries=False, + show_not_completed_ops_info=False) + + self.props.tooltip = _('Copy') + + self._metadata_list = None + self._fill_and_pop_up_options(None) + + def _get_actionable_signal(self): + return 'clicked' + + def _fill_and_pop_up_options(self, widget_clicked): + for child in self.props.palette.menu.get_children(): + self.props.palette.menu.remove(child) + + COPY_MENU_HELPER.insert_copy_to_menu_items(self.props.palette.menu, + [], + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True) + if widget_clicked is not None: + self.props.palette.popup(immediate=True, state=1) + + +class MultiSelectEntriesInfoWidget(Gtk.ToolItem): + def __init__(self): + Gtk.ToolItem.__init__(self) + + self._box = Gtk.VBox() + self._selected_entries = 0 + + self._label = Gtk.Label() + self._box.pack_start(self._label, True, True, 0) + + self._progress_label = Gtk.Label() + self._box.pack_start(self._progress_label, True, True, 0) + + self.add(self._box) + + self.show_all() + self._box.show_all() + self._progress_label.hide() + + def set_total_number_of_entries(self, total): + self._total = total + + def update_progress(self, fraction): + percent = '%.02f' % (fraction * 100) + + # TRANS: Do not translate %.02f + text = '%.02f%% complete' % (fraction * 100) + if (str(percent) != '100.00') and (str(percent).endswith('00')): + self._progress_label.set_text(text) + self._progress_label.show() + self.show_all() + Gdk.Window.process_all_updates() + else: + self._progress_label.hide() + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + self.hide() + + def update_text(self, primary_text, secondary_text, special_action, + update_selected_entries): + # If "special_action" is None, + # we need to display the info, conveyed by + # "primary_message" and "secondary_message" + # + # If "special_action" is True, + # a new entry has been selected. + # + # If "special_action" is False, + # an enrty has been deselected. + if special_action == None: + self._label.set_text(primary_text + secondary_text) + self._label.show() + else: + if update_selected_entries: + if special_action == True: + self._selected_entries = self._selected_entries + 1 + elif special_action == False: + self._selected_entries = self._selected_entries - 1 + + # TRANS: Do not translate the two "%d". + message = _('Selected %d of %d') % (self._selected_entries, + self._total) + + # Only show the "selected x of y" for "Select All", or + # "Deselect All", or if the user checked/unchecked a + # checkbox. + from jarabe.journal.palettes import get_current_action_item + current_action_item = get_current_action_item() + if current_action_item == None or \ + isinstance(current_action_item, SelectAllButton) or \ + isinstance(current_action_item, SelectNoneButton): + self._label.set_text(message) + self._label.show() + + Gdk.Window.process_all_updates() + + def get_current_entry_number(self): + return self._selected_entries + + class SortingButton(ToolButton): __gtype_name__ = 'JournalSortingButton' diff --git a/src/jarabe/journal/journalwindow.py b/src/jarabe/journal/journalwindow.py index 776a495..8fcecaf 100644 --- a/src/jarabe/journal/journalwindow.py +++ b/src/jarabe/journal/journalwindow.py @@ -15,6 +15,8 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +from gi.repository import Gdk + from sugar3.graphics.window import Window _journal_window = None @@ -31,3 +33,46 @@ class JournalWindow(Window): def get_journal_window(): return _journal_window + + +def set_widgets_active_state(active_state): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.get_toolbar_box().set_sensitive(active_state) + journal.get_list_view().set_sensitive(active_state) + journal.get_volumes_toolbar().set_sensitive(active_state) + + +def show_waiting_cursor(): + # Only show waiting-cursor, if this is the batch-mode. + + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + return + + _journal_window.get_root_window().set_cursor(Gdk.Cursor.new(Gdk.CursorType.WATCH)) + + +def freeze_ui(): + # Only freeze, if this is the batch-mode. + + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + return + + show_waiting_cursor() + + set_widgets_active_state(False) + + +def show_normal_cursor(): + _journal_window.get_root_window().set_cursor(Gdk.Cursor.new(Gdk.CursorType.LEFT_PTR)) + + +def unfreeze_ui(): + # Unfreeze, irrespective of whether this is the batch mode. + + set_widgets_active_state(True) + + show_normal_cursor() diff --git a/src/jarabe/journal/keepicon.py b/src/jarabe/journal/keepicon.py index 16e3a57..9c7b7d5 100644 --- a/src/jarabe/journal/keepicon.py +++ b/src/jarabe/journal/keepicon.py @@ -22,6 +22,8 @@ from sugar3.graphics.icon import Icon from sugar3.graphics import style from sugar3.graphics.xocolor import XoColor +from jarabe.journal import model + class KeepIcon(Gtk.ToggleButton): def __init__(self): @@ -37,6 +39,9 @@ class KeepIcon(Gtk.ToggleButton): self.connect('enter-notify-event', self.__enter_notify_event_cb) def __toggled_cb(self, widget): + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if self.get_active(): client = GConf.Client.get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) @@ -47,9 +52,15 @@ class KeepIcon(Gtk.ToggleButton): self._icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() def __enter_notify_event_cb(self, icon, event): + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if not self.get_active(): self._icon.props.fill_color = style.COLOR_BUTTON_GREY.get_svg() def __leave_notify_event_cb(self, icon, event): + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if not self.get_active(): self._icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() diff --git a/src/jarabe/journal/listmodel.py b/src/jarabe/journal/listmodel.py index b98d01c..a5bb7b0 100644 --- a/src/jarabe/journal/listmodel.py +++ b/src/jarabe/journal/listmodel.py @@ -54,6 +54,7 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): COLUMN_BUDDY_1 = 9 COLUMN_BUDDY_2 = 10 COLUMN_BUDDY_3 = 11 + COLUMN_SELECT = 12 _COLUMN_TYPES = { COLUMN_UID: str, @@ -68,6 +69,7 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): COLUMN_BUDDY_1: object, COLUMN_BUDDY_3: object, COLUMN_BUDDY_2: object, + COLUMN_SELECT: bool, } _PAGE_SIZE = 10 @@ -79,6 +81,8 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): self._cached_row = None self._result_set = model.find(query, ListModel._PAGE_SIZE) self._temp_drag_file_path = None + self._selected = {} + self._uid_metadata_assoc = {} # HACK: The view will tell us that it is resizing so the model can # avoid hitting D-Bus and disk. @@ -248,3 +252,22 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): return True return False + + def update_uid_metadata_assoc(self, uid, metadata): + self._uid_metadata_assoc[uid] = metadata + + def set_selected_value(self, uid, value): + if value == False: + del self._selected[uid] + elif value == True: + self._selected[uid] = value + + def get_selected_value(self, uid): + if self._selected.has_key(uid): + return True + else: + return False + + def get_in_memory_metadata(self, path): + uid = self[path][ListModel.COLUMN_UID] + return self._uid_metadata_assoc[uid] diff --git a/src/jarabe/journal/listview.py b/src/jarabe/journal/listview.py index 5b2c5ab..35c8092 100644 --- a/src/jarabe/journal/listview.py +++ b/src/jarabe/journal/listview.py @@ -67,7 +67,8 @@ class BaseListView(Gtk.Bin): 'clear-clicked': (GObject.SignalFlags.RUN_FIRST, None, ([])), } - def __init__(self): + def __init__(self, is_object_chooser): + self._is_object_chooser = is_object_chooser self._query = {} self._model = None self._progress_bar = None @@ -100,11 +101,11 @@ class BaseListView(Gtk.Bin): self._title_column = None self.sort_column = None self._add_columns() + self._inhibit_refresh = False + self._selected_entries = 0 + + self.enable_drag_and_copy() - self.tree_view.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK, - [('text/uri-list', 0, 0), - ('journal-object-id', 0, 0)], - Gdk.DragAction.COPY) # Auto-update stuff self._fully_obscured = True @@ -116,6 +117,15 @@ class BaseListView(Gtk.Bin): model.updated.connect(self.__model_updated_cb) model.deleted.connect(self.__model_deleted_cb) + def enable_drag_and_copy(self): + self.tree_view.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK, + [('text/uri-list', 0, 0), + ('journal-object-id', 0, 0)], + Gdk.DragAction.COPY) + + def disable_drag_and_copy(self): + self.tree_view.unset_rows_drag_source() + def __model_created_cb(self, sender, signal, object_id): if self._is_new_item_visible(object_id): self._set_dirty() @@ -136,6 +146,17 @@ class BaseListView(Gtk.Bin): return object_id.startswith(self._query['mountpoints'][0]) def _add_columns(self): + if not self._is_object_chooser: + cell_select = CellRendererToggle(self.tree_view) + cell_select.connect('clicked', self.__cell_select_clicked_cb) + + column = Gtk.TreeViewColumn() + column.props.sizing = Gtk.TreeViewColumnSizing.FIXED + column.props.fixed_width = cell_select.props.width + column.pack_start(cell_select, True) + column.set_cell_data_func(cell_select, self.__select_set_data_cb) + self.tree_view.append_column(column) + cell_favorite = CellRendererFavorite(self.tree_view) cell_favorite.connect('clicked', self.__favorite_clicked_cb) @@ -248,8 +269,30 @@ class BaseListView(Gtk.Bin): def __favorite_set_data_cb(self, column, cell, tree_model, tree_iter, data): - favorite = tree_model[tree_iter][ListModel.COLUMN_FAVORITE] - if favorite: + # Instead of querying the favorite-status from the "cached" + # entries in listmodel, hit the DS, and retrieve the persisted + # favorite-status. + # This solves the issue in "Multi-Select", wherein the + # listview is inhibited from refreshing. Now, if the user + # clicks favorite-star-icon(s), the change(s) is(are) written + # to the DS, but no refresh takes place. Thus, in order to have + # the change(s) reflected on the UI, we need to hit the DS for + # querying the favorite-status (instead of relying on the + # cached-listmodel. + uid = tree_model[tree_iter][ListModel.COLUMN_UID] + if uid is None: + return + + try: + metadata = model.get(uid) + except: + return + + favorite = None + if 'keep' in metadata.keys(): + favorite = str(metadata['keep']) + + if favorite == '1': client = GConf.Client.get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) cell.props.xo_color = color @@ -257,6 +300,11 @@ class BaseListView(Gtk.Bin): cell.props.xo_color = None def __favorite_clicked_cb(self, cell, path): + # If this is a remote-share, return without doing any + # processing. + if model.is_current_mount_point_for_remote_share(model.LIST_VIEW): + return + row = self._model[path] metadata = model.get(row[ListModel.COLUMN_UID]) if not model.is_editable(metadata): @@ -265,7 +313,94 @@ class BaseListView(Gtk.Bin): metadata['keep'] = '0' else: metadata['keep'] = '1' - model.write(metadata, update_mtime=False) + + from jarabe.journal.journalactivity import get_mount_point + metadata['mountpoint'] = get_mount_point() + + model.update_only_metadata_and_preview_files_and_return_file_paths(metadata) + self.__redraw_view_if_necessary() + + def __select_set_data_cb(self, column, cell, tree_model, tree_iter, + data): + uid = tree_model[tree_iter][ListModel.COLUMN_UID] + if uid is None: + return + + # Hack to associate the cell with the metadata, so that it (the + # cell) is available offline as well (example during + # batch-operations, when the processing has to be done, without + # actually clicking any cell. + try: + metadata = model.get(uid) + except: + # https://dev.laptop.org.au/issues/1119 + # http://bugs.sugarlabs.org/ticket/3344 + # Occurs, when copying entries from journal to pen-drive. + # Simply swallow the exception, and return, as this too, + # like the above case, does not have any impact on the + # functionality. + return + + metadata['cell'] = cell + tree_model.update_uid_metadata_assoc(uid, metadata) + + self.do_ui_select_change(metadata) + + def __cell_select_clicked_cb(self, cell, path): + row = self._model[path] + treeiter = self._model.get_iter(path) + metadata = model.get(row[ListModel.COLUMN_UID]) + self.do_backend_select_change(metadata) + + def do_ui_select_change(self, metadata): + tree_model = self.get_model() + selected = tree_model.get_selected_value(metadata['uid']) + + if 'cell' in metadata.keys(): + cell = metadata['cell'] + if selected: + cell.props.icon_name = 'emblem-checked' + else: + cell.props.icon_name = 'emblem-unchecked' + + def do_backend_select_change(self, metadata): + uid = metadata['uid'] + selected = self._model.get_selected_value(uid) + + self._model.set_selected_value(uid, not selected) + self._process_new_selected_status(not selected) + + def _process_new_selected_status(self, new_status): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + journal_toolbar_box = journal.get_toolbar_box() + + self.__redraw_view_if_necessary() + + if new_status == False: + self._selected_entries = self._selected_entries - 1 + journal_toolbar_box.process_new_deselected_entry_in_multi_select() + GObject.idle_add(self._post_backend_processing) + else: + self._selected_entries = self._selected_entries + 1 + journal.get_list_view().inhibit_refresh(True) + journal.switch_to_editing_mode(True) + + # For the case, when we are switching to editing-mode. + # The previous call won't actually redraw, as we are not in + # editing-mode that time. + self.__redraw_view_if_necessary() + + journal.get_toolbar_box().process_new_selected_entry_in_multi_select() + + def _post_backend_processing(self): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + if self._selected_entries == 0: + journal.switch_to_editing_mode(False) + journal.get_list_view().inhibit_refresh(False) + journal.get_list_view().refresh() def update_with_query(self, query_dict): logging.debug('ListView.update_with_query') @@ -282,6 +417,11 @@ class BaseListView(Gtk.Bin): self.refresh() def refresh(self): + if not self._inhibit_refresh: + self.set_sensitive(True) + self.proceed_with_refresh() + + def proceed_with_refresh(self): logging.debug('ListView.refresh query %r', self._query) self._stop_progress_bar() @@ -482,6 +622,64 @@ class BaseListView(Gtk.Bin): self.update_dates() return True + def get_model(self): + return self._model + + def inhibit_refresh(self, inhibit): + self._inhibit_refresh = inhibit + + def __redraw_view_if_necessary(self): + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + return + + # First, get the total number of entries, for which the + # batch-operation is under progress. + from jarabe.journal.palettes import get_current_action_item + + current_action_item = get_current_action_item() + if current_action_item is None: + # A single checkbox has been clicked/unclicked. + self.__redraw() + return + + total_items = current_action_item.get_number_of_entries_to_operate_upon() + + # Then, get the current entry being processed. + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + current_entry_number = journal.get_toolbar_box().get_current_entry_number() + + # Redraw, if "current_entry_number" is 10. + if current_entry_number == 10: + self.__log(current_entry_number, total_items) + self.__redraw() + return + + # Redraw, if this is the last entry. + if current_entry_number == total_items: + self.__log(current_entry_number, total_items) + self.__redraw() + return + + # Redraw, if this is the 20% interval. + twenty_percent_of_total_items = total_items / 5 + if twenty_percent_of_total_items < 10: + return + + if (current_entry_number % twenty_percent_of_total_items) == 0: + self.__log(current_entry_number, total_items) + self.__redraw() + return + + def __log(self, current_entry_number, total_items): + pass + + def __redraw(self): + tree_view_window = self.tree_view.get_bin_window() + tree_view_window.hide() + tree_view_window.show() + class ListView(BaseListView): __gtype_name__ = 'JournalListView' @@ -497,8 +695,8 @@ class ListView(BaseListView): ([])), } - def __init__(self): - BaseListView.__init__(self) + def __init__(self, is_object_chooser=False): + BaseListView.__init__(self, is_object_chooser) self._is_dragging = False self.tree_view.connect('drag-begin', self.__drag_begin_cb) @@ -559,11 +757,25 @@ class ListView(BaseListView): self.emit('volume-error', message, severity) def __icon_clicked_cb(self, cell, path): + # For locally-mounted remote shares, we do not want to launch + # by clicking on the icons. + # So, check if this is a part of locally-mounted-remote share, + # and if yes, return, without doing anything. + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + return + row = self.tree_view.get_model()[path] metadata = model.get(row[ListModel.COLUMN_UID]) misc.resume(metadata) def __cell_title_edited_cb(self, cell, path, new_text): + from jarabe.journal.journalactivity import get_journal, \ + get_mount_point + if get_journal().is_editing_mode_present(): + return + row = self._model[path] metadata = model.get(row[ListModel.COLUMN_UID]) metadata['title'] = new_text @@ -592,6 +804,25 @@ class CellRendererFavorite(CellRendererIcon): self.props.prelit_stroke_color = prelit_color.get_stroke_color() self.props.prelit_fill_color = prelit_color.get_fill_color() + def do_render(self, cr, widget, background_area, cell_area, flags): + # If this is a remote-share, mask the "PRELIT" flag. + if model.is_current_mount_point_for_remote_share(model.LIST_VIEW): + flags = flags & (~(Gtk.CellRendererState.PRELIT)) + + CellRendererIcon.do_render(self, cr, widget, background_area, cell_area, flags) + +class CellRendererToggle(CellRendererIcon): + __gtype_name__ = 'JournalCellRendererSelect' + + def __init__(self, tree_view): + CellRendererIcon.__init__(self, tree_view) + + self.props.width = style.GRID_CELL_SIZE + self.props.height = style.GRID_CELL_SIZE + self.props.size = style.SMALL_ICON_SIZE + self.props.icon_name = 'checkbox-unchecked' + self.props.mode = Gtk.CellRendererMode.ACTIVATABLE + class CellRendererDetail(CellRendererIcon): __gtype_name__ = 'JournalCellRendererDetail' @@ -636,6 +867,11 @@ class CellRendererActivityIcon(CellRendererIcon): if not self._show_palette: return None + # Also, if we are in batch-operations mode, return 'None' + from jarabe.journal.journalactivity import get_journal + if get_journal().is_editing_mode_present(): + return None + tree_model = self.tree_view.get_model() metadata = tree_model.get_metadata(self.props.palette_invoker.path) diff --git a/src/jarabe/journal/misc.py b/src/jarabe/journal/misc.py index efd0dbe..877c1ab 100644 --- a/src/jarabe/journal/misc.py +++ b/src/jarabe/journal/misc.py @@ -312,3 +312,37 @@ def get_icon_color(metadata): return XoColor(client.get_string('/desktop/sugar/user/color')) else: return XoColor(metadata['icon-color']) + + +def get_xo_serial(): + _OFW_TREE = '/ofw' + _PROC_TREE = '/proc/device-tree' + _SN = 'serial-number' + _not_available = _('Not available') + + serial_no = None + if os.path.exists(os.path.join(_OFW_TREE, _SN)): + serial_no = read_file(os.path.join(_OFW_TREE, _SN)) + elif os.path.exists(os.path.join(_PROC_TREE, _SN)): + serial_no = read_file(os.path.join(_PROC_TREE, _SN)) + + if serial_no is None: + serial_no = _not_available + + # Remove the trailing binary character, else DBUS will crash. + return serial_no.rstrip('\x00') + + +def read_file(path): + if os.access(path, os.R_OK) == 0: + return None + + fd = open(path, 'r') + value = fd.read() + fd.close() + if value: + value = value.strip('\n') + return value + else: + logging.debug('No information in file or directory: %s', path) + return None diff --git a/src/jarabe/journal/model.py b/src/jarabe/journal/model.py index 0a5b354..c9e08ec 100644 --- a/src/jarabe/journal/model.py +++ b/src/jarabe/journal/model.py @@ -16,6 +16,7 @@ import logging import os +import stat import errno import subprocess from datetime import datetime @@ -37,6 +38,8 @@ from sugar3 import dispatch from sugar3 import mime from sugar3 import util +from jarabe.journal import webdavmanager + DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' @@ -50,14 +53,99 @@ PROPERTIES = ['activity', 'activity_id', 'buddies', 'bundle_id', MIN_PAGES_TO_CACHE = 3 MAX_PAGES_TO_CACHE = 5 +WEBDAV_MOUNT_POINT = '/tmp/' +LOCAL_SHARES_MOUNT_POINT = '/var/www/web1/web/' + JOURNAL_METADATA_DIR = '.Sugar-Metadata' +LIST_VIEW = 1 +DETAIL_VIEW = 2 + _datastore = None created = dispatch.Signal() updated = dispatch.Signal() deleted = dispatch.Signal() +SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME_PATH = \ + '/desktop/sugar/network/school_server_ip_address_or_dns_name' +IS_PEER_TO_PEER_SHARING_AVAILABLE_PATH = \ + '/desktop/sugar/network/is_peer_to_peer_sharing_available' + +client = GConf.Client.get_default() +SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME = client.get_string(SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME_PATH) or '' +IS_PEER_TO_PEER_SHARING_AVAILABLE = client.get_bool(IS_PEER_TO_PEER_SHARING_AVAILABLE_PATH) + + + +def is_school_server_present(): + return not (SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME is '') + + +def is_peer_to_peer_sharing_available(): + return IS_PEER_TO_PEER_SHARING_AVAILABLE == True + + +def _get_mount_point(path): + dir_path = os.path.dirname(path) + while dir_path: + if os.path.ismount(dir_path): + return dir_path + else: + dir_path = dir_path.rsplit(os.sep, 1)[0] + return None + + +def _check_remote_sharing_mount_point(mount_point, share_type): + from jarabe.journal.journalactivity import get_journal + + mount_point_button = get_journal().get_volumes_toolbar()._get_button_for_mount_point(mount_point) + if mount_point_button._share_type == share_type: + return True + return False + + +def is_mount_point_for_school_server(mount_point): + from jarabe.journal.volumestoolbar import SHARE_TYPE_SCHOOL_SERVER + return _check_remote_sharing_mount_point(mount_point, SHARE_TYPE_SCHOOL_SERVER) + + +def is_mount_point_for_peer_share(mount_point): + from jarabe.journal.volumestoolbar import SHARE_TYPE_PEER + return _check_remote_sharing_mount_point(mount_point, SHARE_TYPE_PEER) + + +def is_current_mount_point_for_remote_share(view_type): + from jarabe.journal.journalactivity import get_journal, get_mount_point + if view_type == LIST_VIEW: + current_mount_point = get_mount_point() + elif view_type == DETAIL_VIEW: + current_mount_point = get_journal().get_detail_toolbox().get_mount_point() + + if is_mount_point_for_locally_mounted_remote_share(current_mount_point): + return True + return False + + +def extract_ip_address_or_dns_name_from_locally_mounted_remote_share_path(path): + """ + Path is of type :: + + /tmp/1.2.3.4/webdav/a.txt; OR + /tmp/this.is.dns.name/a.txt + """ + return path.split('/')[2] + + +def is_mount_point_for_locally_mounted_remote_share(mount_point): + """ + The mount-point can be either of the ip-Address, or the DNS name. + More importantly, whatever the "name" be, it does NOT have a + forward-slash. + """ + return mount_point.find(WEBDAV_MOUNT_POINT) == 0 + + class _Cache(object): __gtype_name__ = 'model_Cache' @@ -422,6 +510,127 @@ class InplaceResultSet(BaseResultSet): return +class RemoteShareResultSet(object): + def __init__(self, ip_address_or_dns_name, query): + self._ip_address_or_dns_name = ip_address_or_dns_name + self._file_list = [] + + self.ready = dispatch.Signal() + self.progress = dispatch.Signal() + + # First time, query is none. + if query is None: + return + + query_text = query.get('query', '') + if query_text.startswith('"') and query_text.endswith('"'): + self._regex = re.compile('*%s*' % query_text.strip(['"'])) + elif query_text: + expression = '' + for word in query_text.split(' '): + expression += '(?=.*%s.*)' % word + self._regex = re.compile(expression, re.IGNORECASE) + else: + self._regex = None + + if query.get('timestamp', ''): + self._date_start = int(query['timestamp']['start']) + self._date_end = int(query['timestamp']['end']) + else: + self._date_start = None + self._date_end = None + + self._mime_types = query.get('mime_type', []) + + self._sort = query.get('order_by', ['+timestamp'])[0] + + def setup(self): + try: + metadata_list_complete = webdavmanager.get_remote_webdav_share_metadata(self._ip_address_or_dns_name) + except Exception, e: + metadata_list_complete = [] + + for metadata in metadata_list_complete: + + add_to_list = False + if self._regex is not None: + for f in ['fulltext', 'title', + 'description', 'tags']: + if f in metadata and \ + self._regex.match(metadata[f]): + add_to_list = True + break + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._date_start is not None: + if metadata['timestamp'] > self._date_start: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._date_end is not None: + if metadata['timestamp'] < self._date_end: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._mime_types: + mime_type = metadata['mime_type'] + if mime_type in self._mime_types: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + # If control reaches here, the current metadata has passed + # out all filter-tests. + file_info = (metadata['timestamp'], + metadata['creation_time'], + metadata['filesize'], + metadata) + self._file_list.append(file_info) + + if self._sort[1:] == 'filesize': + keygetter = itemgetter(2) + elif self._sort[1:] == 'creation_time': + keygetter = itemgetter(1) + else: + # timestamp + keygetter = itemgetter(0) + + self._file_list.sort(lambda a, b: cmp(b, a), + key=keygetter, + reverse=(self._sort[0] == '-')) + + self.ready.send(self) + + def get_length(self): + return len(self._file_list) + + length = property(get_length) + + def seek(self, position): + self._position = position + + def read(self): + modified_timestamp, creation_timestamp, filesize, metadata = self._file_list[self._position] + return metadata + + def stop(self): + self._stopped = True + + def _get_file_metadata(path, stat, fetch_preview=True): """Return the metadata from the corresponding file. @@ -434,9 +643,13 @@ def _get_file_metadata(path, stat, fetch_preview=True): metadata = _get_file_metadata_from_json(dir_path, filename, fetch_preview) if metadata: if 'filesize' not in metadata: - metadata['filesize'] = stat.st_size + if stat is not None: + metadata['filesize'] = stat.st_size return metadata + if stat is None: + raise ValueError('File does not exist') + mime_type, uncertain_result_ = Gio.content_type_guess(filename=path, data=None) return {'uid': path, @@ -457,10 +670,17 @@ def _get_file_metadata_from_json(dir_path, filename, fetch_preview): If the metadata is corrupted we do remove it and the preview as well. """ + + # In case of nested mount-points, (eg. ~/Documents/in1/in2/in3.txt), + # "dir_path = ~/Documents/in1/in2"; while + # "metadata_dir_path = ~/Documents". + from jarabe.journal.journalactivity import get_mount_point + metadata_dir_path = get_mount_point() + metadata = None - metadata_path = os.path.join(dir_path, JOURNAL_METADATA_DIR, + metadata_path = os.path.join(metadata_dir_path, JOURNAL_METADATA_DIR, filename + '.metadata') - preview_path = os.path.join(dir_path, JOURNAL_METADATA_DIR, + preview_path = os.path.join(metadata_dir_path, JOURNAL_METADATA_DIR, filename + '.preview') if not os.path.exists(metadata_path): @@ -529,6 +749,9 @@ def find(query_, page_size): if mount_points[0] == '/': return DatastoreResultSet(query, page_size) + elif is_mount_point_for_locally_mounted_remote_share(mount_points[0]): + ip_address = extract_ip_address_or_dns_name_from_locally_mounted_remote_share_path(mount_points[0]) + return RemoteShareResultSet(ip_address, query) else: return InplaceResultSet(query, page_size, mount_points[0]) @@ -546,8 +769,12 @@ def _get_mount_point(path): def get(object_id): """Returns the metadata for an object """ - if os.path.exists(object_id): - stat = os.stat(object_id) + if (object_id[0] == '/'): + if os.path.exists(object_id): + stat = os.stat(object_id) + else: + stat = None + metadata = _get_file_metadata(object_id, stat) metadata['mountpoint'] = _get_mount_point(object_id) else: @@ -620,7 +847,21 @@ def delete(object_id): def copy(metadata, mount_point): """Copies an object to another mount point """ + # In all cases (except one), "copy" means the actual duplication of + # the content. + # Only in case of remote downloading, the content is first copied + # to "/tmp" folder. In those cases, copying would refer to a mere + # renaming. + transfer_ownership = False + + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + if is_mount_point_for_locally_mounted_remote_share(current_mount_point): + transfer_ownership = True + metadata = get(metadata['uid']) + if mount_point == '/' and metadata['icon-color'] == '#000000,#ffffff': client = GConf.Client.get_default() metadata['icon-color'] = client.get_string('/desktop/sugar/user/color') @@ -631,7 +872,7 @@ def copy(metadata, mount_point): metadata['mountpoint'] = mount_point del metadata['uid'] - return write(metadata, file_path, transfer_ownership=False) + return write(metadata, file_path, transfer_ownership=transfer_ownership) def write(metadata, file_path='', update_mtime=True, transfer_ownership=True): @@ -653,22 +894,113 @@ def write(metadata, file_path='', update_mtime=True, transfer_ownership=True): object_id = _get_datastore().create(dbus.Dictionary(metadata), file_path, transfer_ownership) + elif metadata.get('mountpoint', '/') == (WEBDAV_MOUNT_POINT + SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME): + filename = metadata['title'] + + ip_address_or_dns_name = SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME + webdavmanager.get_remote_webdav_share_metadata(ip_address_or_dns_name) + + data_webdav_manager = \ + webdavmanager.get_data_webdav_manager(ip_address_or_dns_name) + metadata_webdav_manager = \ + webdavmanager.get_metadata_webdav_manager(ip_address_or_dns_name) + + + # If we get a resource by this name, there is already an entry + # on the server with this name; we do not want to do any + # overwrites. + data_resource = webdavmanager.get_resource_by_resource_key(data_webdav_manager, + '/webdav/' + filename) + metadata_resource = webdavmanager.get_resource_by_resource_key(metadata_webdav_manager, + '/webdav/.Sugar-Metadata/' + filename + '.metadata') + if (data_resource is not None) or (metadata_resource is not None): + raise Exception(_('Entry already present on the server with ' + 'this name. Try again after renaming.')) + + # No entry for this name present. + # So, first write the metadata- and preview-file to temporary + # locations. + metadata_file_path, preview_file_path = \ + _write_metadata_and_preview_files_and_return_file_paths(metadata, + filename) + + # Finally, + # Upload the data file. + webdavmanager.add_resource_by_resource_key(data_webdav_manager, + filename, + file_path) + + # Upload the preview file. + if preview_file_path is not None: + webdavmanager.add_resource_by_resource_key(metadata_webdav_manager, + filename + '.preview', + preview_file_path) + + # Upload the metadata file. + # + # Note that this needs to be the last step. If there was any + # error uploading the data- or the preview-file, control would + # not reach here. + # + # In other words, the control reaches here only if the data- + # and the preview- files have been uploaded. Finally, IF this + # file is successfully uploaded, we have the guarantee that all + # files for a particular journal entry are in place. + webdavmanager.add_resource_by_resource_key(metadata_webdav_manager, + filename + '.metadata', + metadata_file_path) + + + object_id = 'doesn\'t matter' + else: - object_id = _write_entry_on_external_device(metadata, file_path) + object_id = _write_entry_on_external_device(metadata, + file_path, + transfer_ownership) return object_id -def _rename_entry_on_external_device(file_path, destination_path, - metadata_dir_path): +def make_file_fully_permissible(file_path): + fd = os.open(file_path, os.O_RDONLY) + os.fchmod(fd, stat.S_IRWXU | stat.S_IRWXG |stat.S_IRWXO) + os.close(fd) + + +def _rename_entry_on_external_device(file_path, destination_path): """Rename an entry with the associated metadata on an external device.""" old_file_path = file_path if old_file_path != destination_path: - os.rename(file_path, destination_path) + # Strangely, "os.rename" works fine on sugar-jhbuild, but fails + # on XOs, wih the OSError 13 ("invalid cross-device link"). So, + # using the system call "mv". + os.system('mv "%s" "%s"' % (file_path, destination_path)) + make_file_fully_permissible(destination_path) + + + # In renaming, we want to delete the metadata-, and preview- + # files of the current mount-point, and not the destination + # mount-point. + # But we also need to ensure that the directory of + # 'old_file_path' and 'destination_path' are not same. + if os.path.dirname(old_file_path) == os.path.dirname(destination_path): + return + + from jarabe.journal.journalactivity import get_mount_point + + # Also, as a special case, the metadata- and preview-files of + # the remote-shares must never be deleted. For them, only the + # data-file needs to be moved. + if is_mount_point_for_locally_mounted_remote_share(get_mount_point()): + return + + + source_metadata_dir_path = get_mount_point() + '/.Sugar-Metadata' + old_fname = os.path.basename(file_path) - old_files = [os.path.join(metadata_dir_path, + old_files = [os.path.join(source_metadata_dir_path, old_fname + '.metadata'), - os.path.join(metadata_dir_path, + os.path.join(source_metadata_dir_path, old_fname + '.preview')] for ofile in old_files: if os.path.exists(ofile): @@ -679,41 +1011,32 @@ def _rename_entry_on_external_device(file_path, destination_path, 'for file=%s', ofile, old_fname) -def _write_entry_on_external_device(metadata, file_path): - """Create and update an entry copied from the - DS to an external storage device. - - Besides copying the associated file a file for the preview - and one for the metadata are stored in the hidden directory - .Sugar-Metadata. - - This function handles renames of an entry on the - external device and avoids name collisions. Renames are - handled failsafe. - - """ - if 'uid' in metadata and os.path.exists(metadata['uid']): - file_path = metadata['uid'] +def _write_metadata_and_preview_files_and_return_file_paths(metadata, + file_name): + metadata_copy = metadata.copy() + metadata_copy.pop('mountpoint', None) + metadata_copy.pop('uid', None) - if not file_path or not os.path.exists(file_path): - raise ValueError('Entries without a file cannot be copied to ' - 'removable devices') - if not metadata.get('title'): - metadata['title'] = _('Untitled') - file_name = get_file_name(metadata['title'], metadata['mime_type']) + # For copying to School-Server, we need to retain this property. + # Else wise, I have no idea why this property is being removed !! + if (is_mount_point_for_locally_mounted_remote_share(metadata.get('mountpoint', '/')) == False) and \ + (metadata.get('mountpoint', '/') != LOCAL_SHARES_MOUNT_POINT): + metadata_copy.pop('filesize', None) - destination_path = os.path.join(metadata['mountpoint'], file_name) - if destination_path != file_path: - file_name = get_unique_file_name(metadata['mountpoint'], file_name) - destination_path = os.path.join(metadata['mountpoint'], file_name) - clean_name, extension_ = os.path.splitext(file_name) - metadata['title'] = clean_name + # For journal case, there is the special treatment. + if metadata.get('mountpoint', '/') == '/': + if metadata.get('uid', ''): + object_id = _get_datastore().update(metadata['uid'], + dbus.Dictionary(metadata), + '', + False) + else: + object_id = _get_datastore().create(dbus.Dictionary(metadata), + '', + False) + return - metadata_copy = metadata.copy() - metadata_copy.pop('mountpoint', None) - metadata_copy.pop('uid', None) - metadata_copy.pop('filesize', None) metadata_dir_path = os.path.join(metadata['mountpoint'], JOURNAL_METADATA_DIR) @@ -742,11 +1065,64 @@ def _write_entry_on_external_device(metadata, file_path): os.close(fh) os.rename(fn, os.path.join(metadata_dir_path, preview_fname)) - if not os.path.dirname(destination_path) == os.path.dirname(file_path): - shutil.copy(file_path, destination_path) + metadata_destination_path = os.path.join(metadata_dir_path, file_name + '.metadata') + make_file_fully_permissible(metadata_destination_path) + if preview: + preview_destination_path = os.path.join(metadata_dir_path, preview_fname) + make_file_fully_permissible(preview_destination_path) + else: + preview_destination_path = None + + return (metadata_destination_path, preview_destination_path) + + +def update_only_metadata_and_preview_files_and_return_file_paths(metadata): + file_name = get_file_name(metadata['title'], metadata['mime_type']) + _write_metadata_and_preview_files_and_return_file_paths(metadata, + file_name) + + +def _write_entry_on_external_device(metadata, file_path, + transfer_ownership): + """Create and update an entry copied from the + DS to an external storage device. + + Besides copying the associated file a file for the preview + and one for the metadata are stored in the hidden directory + .Sugar-Metadata. + + This function handles renames of an entry on the + external device and avoids name collisions. Renames are + handled failsafe. + + """ + if 'uid' in metadata and os.path.exists(metadata['uid']): + file_path = metadata['uid'] + + if not file_path or not os.path.exists(file_path): + raise ValueError('Entries without a file cannot be copied to ' + 'removable devices') + + if not metadata.get('title'): + metadata['title'] = _('Untitled') + file_name = get_file_name(metadata['title'], metadata['mime_type']) + + destination_path = os.path.join(metadata['mountpoint'], file_name) + if destination_path != file_path: + file_name = get_unique_file_name(metadata['mountpoint'], file_name) + destination_path = os.path.join(metadata['mountpoint'], file_name) + clean_name, extension_ = os.path.splitext(file_name) + metadata['title'] = clean_name + + _write_metadata_and_preview_files_and_return_file_paths(metadata, + file_name) + + if (os.path.dirname(destination_path) == os.path.dirname(file_path)) or \ + (transfer_ownership == True): + _rename_entry_on_external_device(file_path, destination_path) else: - _rename_entry_on_external_device(file_path, destination_path, - metadata_dir_path) + shutil.copy(file_path, destination_path) + make_file_fully_permissible(destination_path) object_id = destination_path created.send(None, object_id=object_id) @@ -796,7 +1172,17 @@ def is_editable(metadata): if metadata.get('mountpoint', '/') == '/': return True else: - return os.access(metadata['mountpoint'], os.W_OK) + # sl#3605: Instead of relying on mountpoint property being + # present in the metadata, use journalactivity api. + # This would work seamlessly, as "Details View' is + # called, upon an entry in the context of a singular + # mount-point. + from jarabe.journal.journalactivity import get_mount_point + mount_point = get_mount_point() + + if is_mount_point_for_locally_mounted_remote_share(mount_point): + return False + return os.access(mount_point, os.W_OK) def get_documents_path(): diff --git a/src/jarabe/journal/palettes.py b/src/jarabe/journal/palettes.py index 43f9905..f770d55 100644 --- a/src/jarabe/journal/palettes.py +++ b/src/jarabe/journal/palettes.py @@ -15,6 +15,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA from gettext import gettext as _ +from gettext import ngettext import logging import os @@ -38,6 +39,61 @@ from jarabe.model import mimeregistry from jarabe.journal import misc from jarabe.journal import model from jarabe.journal import journalwindow +from jarabe.journal import webdavmanager +from jarabe.journal.journalwindow import freeze_ui, \ + unfreeze_ui, \ + show_normal_cursor, \ + show_waiting_cursor + +from webdav.Connection import WebdavError + + +friends_model = friends.get_model() + +_copy_menu_helper = None +_current_action_item = None + +USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE = _('Cannot perform request. Connection failed.') + + +class PassphraseDialog(Gtk.Dialog): + def __init__(self, callback, metadata): + Gtk.Dialog.__init__(self, flags=Gtk.DialogFlags.MODAL) + self._callback = callback + self._metadata = metadata + self.set_title(_('Passphrase required')) + + # TRANS: Please do not translate the '%s'. + label_text = _('Please enter the passphrase for "%s"' % metadata['title']) + label = Gtk.Label(label_text) + self.vbox.pack_start(label, True, True, 0) + + self.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK) + self.set_default_response(Gtk.ResponseType.OK) + self.add_key_entry() + + self.connect('response', self._key_dialog_response_cb) + self.show_all() + + def add_key_entry(self): + self._entry = Gtk.Entry() + self._entry.connect('activate', self._entry_activate_cb) + self.vbox.pack_start(self._entry, True, True, 0) + self.vbox.set_spacing(6) + self.vbox.show_all() + + self._entry.grab_focus() + + def _entry_activate_cb(self, entry): + self.response(Gtk.ResponseType.OK) + + def get_response_object(self): + return self._response + + def _key_dialog_response_cb(self, widget, response_id): + self.hide() + GObject.idle_add(self._callback, self._metadata, + self._entry.get_text()) class ObjectPalette(Palette): @@ -68,6 +124,9 @@ class ObjectPalette(Palette): Palette.__init__(self, primary_text=title, icon=activity_icon) + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + if misc.get_activities(metadata) or misc.is_bundle(metadata): if metadata.get('activity_id', ''): resume_label = _('Resume') @@ -77,10 +136,14 @@ class ObjectPalette(Palette): resume_with_label = _('Start with') menu_item = MenuItem(resume_label, 'activity-start') menu_item.connect('activate', self.__start_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() menu_item = MenuItem(resume_with_label, 'activity-start') + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() start_with_menu = StartWithMenu(self._metadata) @@ -99,6 +162,15 @@ class ObjectPalette(Palette): self.menu.append(menu_item) menu_item.show() copy_menu = CopyMenu(metadata) + copy_menu_helper = get_copy_menu_helper() + + metadata_list = [] + metadata_list.append(metadata) + copy_menu_helper.insert_copy_to_menu_items(copy_menu, + metadata_list, + False, + False, + False) copy_menu.connect('volume-error', self.__volume_error_cb) menu_item.set_submenu(copy_menu) @@ -112,6 +184,8 @@ class ObjectPalette(Palette): menu_item.show() menu_item = MenuItem(_('Send to'), 'document-send') + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() @@ -127,6 +201,8 @@ class ObjectPalette(Palette): menu_item = MenuItem(_('Erase'), 'list-remove') menu_item.connect('activate', self.__erase_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() @@ -197,123 +273,798 @@ class CopyMenu(Gtk.Menu): __gsignals__ = { 'volume-error': (GObject.SignalFlags.RUN_FIRST, None, - ([str, str])), + ([str, str])), } def __init__(self, metadata): Gtk.Menu.__init__(self) - self._metadata = metadata - clipboard_menu = ClipboardMenu(self._metadata) - clipboard_menu.set_image(Icon(icon_name='toolbar-edit', - icon_size=Gtk.IconSize.MENU)) - clipboard_menu.connect('volume-error', self.__volume_error_cb) - self.append(clipboard_menu) - clipboard_menu.show() +class ActionItem(GObject.GObject): + """ + This class implements the course of actions that happens when clicking + upon an Action-Item (eg. Batch-Copy-Toolbar-button; + Actual-Batch-Copy-To-Journal-button; + Actual-Batch-Copy-To-Documents-button; + Actual-Batch-Copy-To-Mounted-Drive-button; + Actual-Batch-Copy-To-Clipboard-button; + Single-Copy-To-Journal-button; + Single-Copy-To-Documents-button; + Single-Copy-To-Mounted-Drive-button; + Single-Copy-To-Clipboard-button; + Batch-Erase-Button; + Select-None-Toolbar-button; + Select-All-Toolbar-button + """ + __gtype_name__ = 'JournalActionItem' + + def __init__(self, label, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode, + auto_deselect_source_entries, + need_to_popup_options, + operate_on_deselected_entries, + show_not_completed_ops_info): + GObject.GObject.__init__(self) + + self._label = label + + # Make a copy. + self._immutable_metadata_list = [] + for metadata in metadata_list: + self._immutable_metadata_list.append(metadata) + + self._metadata_list = metadata_list + self._show_progress_info_alert = show_progress_info_alert + self._batch_mode = batch_mode + self._auto_deselect_source_entries = \ + auto_deselect_source_entries + self._need_to_popup_options = \ + need_to_popup_options + self._operate_on_deselected_entries = \ + operate_on_deselected_entries + self._show_not_completed_ops_info = \ + show_not_completed_ops_info + + actionable_signal = self._get_actionable_signal() + + if need_to_popup_options: + self.connect(actionable_signal, self._pre_fill_and_pop_up_options) + else: + if show_editing_alert: + self.connect(actionable_signal, self._show_editing_alert) + else: + self.connect(actionable_signal, + self._pre_operate_per_action, + Gtk.ResponseType.OK) + + def _get_actionable_signal(self): + """ + Some widgets like 'buttons' have 'clicked' as actionable signal; + some like 'menuitems' have 'activate' as actionable signal. + """ + + raise NotImplementedError + + def _pre_fill_and_pop_up_options(self, widget_clicked): + self._set_current_action_item_widget() + self._fill_and_pop_up_options(widget_clicked) + + def _fill_and_pop_up_options(self, widget_clicked): + """ + Eg. Batch-Copy-Toolbar-button does not do anything by itself + useful; but rather pops-up the actual 'copy-to' options. + """ + + raise NotImplementedError + + def _show_editing_alert(self, widget_clicked): + """ + Upon clicking the actual operation button (eg. + Batch-Erase-Button and Batch-Copy-To-Clipboard button; BUT NOT + Batch-Copy-Toolbar-button, since it does not do anything + actually useful, but only pops-up the actual 'copy-to' options. + """ + + freeze_ui() + GObject.idle_add(self.__show_editing_alert_after_freezing_ui, + widget_clicked) + + def __show_editing_alert_after_freezing_ui(self, widget_clicked): + self._set_current_action_item_widget() + + alert_parameters = self._get_editing_alert_parameters() + title = alert_parameters[0] + message = alert_parameters[1] + operation = alert_parameters[2] + + from jarabe.journal.journalactivity import get_journal + get_journal().update_confirmation_alert(title, message, + self._pre_operate_per_action, + None) + + def _get_editing_alert_parameters(self): + """ + Get the alert parameters for widgets that can show editing + alert. + """ + + self._metadata_list = self._get_metadata_list() + entries_len = len(self._metadata_list) + + title = self._get_editing_alert_title() + message = self._get_editing_alert_message(entries_len) + operation = self._get_editing_alert_operation() + + return (title, message, operation) + + def _get_list_model_len(self): + """ + Get the total length of the model under view. + """ + + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + return len(journal.get_list_view().get_model()) + + def _get_metadata_list(self): + """ + For batch-mode, get the metadata list, according to button-type. + For eg, Select-All-Toolbar-button operates on non-selected entries; + while othere operate on selected-entries. + + For single-mode, simply copy from the + "immutable_metadata_list". + """ + + if self._batch_mode: + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + if self._operate_on_deselected_entries: + metadata_list = journal.get_metadata_list(False) + else: + metadata_list = journal.get_metadata_list(True) - if self._metadata['mountpoint'] != '/': - client = GConf.Client.get_default() - color = XoColor(client.get_string('/desktop/sugar/user/color')) - journal_menu = VolumeMenu(self._metadata, _('Journal'), '/') - journal_menu.set_image(Icon(icon_name='activity-journal', - xo_color=color, - icon_size=Gtk.IconSize.MENU)) - journal_menu.connect('volume-error', self.__volume_error_cb) - self.append(journal_menu) - journal_menu.show() + # Make a backup copy, of this metadata_list. + self._immutable_metadata_list = [] + for metadata in metadata_list: + self._immutable_metadata_list.append(metadata) - volume_monitor = Gio.VolumeMonitor.get() - icon_theme = Gtk.IconTheme.get_default() - for mount in volume_monitor.get_mounts(): - if self._metadata['mountpoint'] == mount.get_root().get_path(): - continue - volume_menu = VolumeMenu(self._metadata, mount.get_name(), - mount.get_root().get_path()) - for name in mount.get_icon().props.names: - if icon_theme.has_icon(name): - volume_menu.set_image(Icon(icon_name=name, - icon_size=Gtk.IconSize.MENU)) - break - volume_menu.connect('volume-error', self.__volume_error_cb) - self.append(volume_menu) - volume_menu.show() + return metadata_list + else: + metadata_list = [] + for metadata in self._immutable_metadata_list: + metadata_list.append(metadata) + return metadata_list + + def _get_editing_alert_title(self): + raise NotImplementedError + + def _get_editing_alert_message(self, entries_len): + raise NotImplementedError + + def _get_editing_alert_operation(self): + raise NotImplementedError + + def _is_metadata_list_empty(self): + return (self._metadata_list is None) or \ + (len(self._metadata_list) == 0) + + def _set_current_action_item_widget(self): + """ + Only set this, if this widget achieves some effective action. + """ + if not self._need_to_popup_options: + global _current_action_item + _current_action_item = self + + def _pre_operate_per_action(self, obj, response_id): + """ + This is the stage, just before the FIRST metadata gets into its + processing cycle. + """ + freeze_ui() + GObject.idle_add(self._pre_operate_per_action_after_done_ui_freezing, + obj, response_id) + + def _pre_operate_per_action_after_done_ui_freezing(self, obj, + response_id): + self._set_current_action_item_widget() + + self._continue_operation = True + + # If the user chose to cancel the operation from the onset, + # simply proceeed to the last. + if response_id == Gtk.ResponseType.CANCEL: + unfreeze_ui() + + self._cancel_further_batch_operation_items() + self._post_operate_per_action() + return - def __volume_error_cb(self, menu_item, message, severity): - self.emit('volume-error', message, severity) + self._skip_all = False + # Also, get the initial length of the model. + self._model_len = self._get_list_model_len() -class VolumeMenu(MenuItem): - __gtype_name__ = 'JournalVolumeMenu' + # Speed Optimisation: + # =================== + # If the metadata-list is empty, fetch it; + # else we have already fetched it, when we showed the + # "editing-alert". + if len(self._metadata_list) == 0: + self._metadata_list = self._get_metadata_list() - __gsignals__ = { - 'volume-error': (GObject.SignalFlags.RUN_FIRST, None, - ([str, str])), - } + # Set the initial length of metadata-list. + self._metadata_list_initial_len = len(self._metadata_list) - def __init__(self, metadata, label, mount_point): - MenuItem.__init__(self, label) - self._metadata = metadata - self.connect('activate', self.__copy_to_volume_cb, mount_point) + self._metadata_processed = 0 - def __copy_to_volume_cb(self, menu_item, mount_point): - file_path = model.get_file(self._metadata['uid']) + # Next, proceed with the metadata + self._pre_operate_per_metadata_per_action() + + def _pre_operate_per_metadata_per_action(self): + """ + This is the stage, just before EVERY metadata gets into doing + its actual work. + """ + + show_waiting_cursor() + GObject.idle_add(self.__pre_operate_per_metadata_per_action_after_freezing_ui) + + def __pre_operate_per_metadata_per_action_after_freezing_ui(self): + from jarabe.journal.journalactivity import get_journal + + # If there is still some metadata left, proceed with the + # metadata operation. + # Else, proceed to post-operations. + if len(self._metadata_list) > 0: + metadata = self._metadata_list.pop(0) + + # If info-alert needs to be shown, show the alert, and + # arrange for actual operation. + # Else, proceed to actual operation directly. + if self._show_progress_info_alert: + current_len = len(self._metadata_list) + + # TRANS: Do not translate the two %d, and the %s. + info_alert_message = _(' %d of %d : %s') % ( + self._metadata_list_initial_len - current_len, + self._metadata_list_initial_len, metadata['title']) + + get_journal().update_info_alert(self._get_info_alert_title(), + info_alert_message) + + # Call the core-function !! + GObject.idle_add(self._operate_per_metadata_per_action, metadata) + else: + self._post_operate_per_action() + + def _get_info_alert_title(self): + raise NotImplementedError + + def _operate_per_metadata_per_action(self, metadata): + """ + This is just a code-convenient-function, which allows + runtime-overriding. It just delegates to the actual + "self._operate" method, the actual which is determined at + runtime. + """ + + if self._continue_operation is False: + # Jump directly to the post-operation + self._post_operate_per_metadata_per_action(metadata) + else: + # Pass the callback for the post-operation-for-metadata. This + # will ensure that async-operations on the metadata are taken + # care of. + if self._operate(metadata) is False: + return + else: + self._metadata_processed = self._metadata_processed + 1 + + def _operate(self, metadata): + """ + Actual, core, productive stage for EVERY metadata. + """ + raise NotImplementedError + + def _post_operate_per_metadata_per_action(self, metadata, + response_id=None): + """ + This is the stage, just after EVERY metadata has been + processed. + """ + self._hide_info_widget_for_single_mode() + + # Toggle the corresponding checkbox - but only for batch-mode. + if self._batch_mode and self._auto_deselect_source_entries: + from jarabe.journal.journalactivity import get_journal + list_view = get_journal().get_list_view() + + list_view.do_ui_select_change(metadata) + list_view.do_backend_select_change(metadata) + + # Call the next ... + self._pre_operate_per_metadata_per_action() + + def _post_operate_per_action(self): + """ + This is the stage, just after the LAST metadata has been + processed. + """ + + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + journal_toolbar_box = journal.get_toolbar_box() + + if self._batch_mode and (not self._auto_deselect_source_entries): + journal_toolbar_box.display_already_selected_entries_status() + + self._process_switching_mode(None, False) + + unfreeze_ui() + + # Set the "_current_action_item" to None. + global _current_action_item + _current_action_item = None + + def _process_switching_mode(self, metadata, ok_clicked=False): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + # Necessary to do this, when the alert needs to be hidden, + # WITHOUT user-intervention. + journal.hide_alert() + + def _refresh(self): + from jarabe.journal.journalactivity import get_journal + get_journal().get_list_view().refresh() + + def _handle_single_mode_notification(self, message, severity): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal._show_alert(message, severity) + self._hide_info_widget_for_single_mode() + + def _hide_info_widget_for_single_mode(self): + if (not self._batch_mode): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.get_toolbar_box().hide_info_widget() + + def _unhide_info_widget_for_single_mode(self): + if not self._batch_mode: + from jarabe.journal.journalactivity import get_journal + get_journal().update_progress(0) + + def _handle_error_alert(self, error_message, metadata): + """ + This handles any error scenarios. Examples are of entries that + display the message "Entries without a file cannot be copied." + This is kind of controller-functionl the model-function is + "self._set_error_info_alert". + """ + + if self._skip_all: + self._post_operate_per_metadata_per_action(metadata) + else: + self._set_error_info_alert(error_message, metadata) + + def _set_error_info_alert(self, error_message, metadata): + """ + This method displays the error alert. + """ + + current_len = len(self._metadata_list) + + # Only show the alert, if allowed to. + if self._show_not_completed_ops_info: + from jarabe.journal.journalactivity import get_journal + get_journal().update_confirmation_alert(_('Error'), + error_message, + self._process_error_skipping, + metadata) + else: + self._process_error_skipping(metadata, gtk.RESPONSE_OK) + + def _process_error_skipping(self, metadata, response_id): + # This sets up the decision, as to whether continue operations + # with the rest of the metadata. + if response_id == Gtk.ResponseType.CANCEL: + self._cancel_further_batch_operation_items() + + self._post_operate_per_metadata_per_action(metadata) + + def _cancel_further_batch_operation_items(self): + self._continue_operation = False + + # Optimization: + # Clear the metadata-list as well. + # This would prevent the unnecessary traversing of the + # remaining checkboxes-corresponding-to-remaining-metadata (of + # course without doing any effective action). + self._metadata_list = [] + + def _file_path_valid(self, metadata): + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + # Now, for locally mounted remote-shares, download the file. + # Note that, always download the file, to avoid the problems + # of stale-cache. + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + file_path = metadata['uid'] + filename = os.path.basename(file_path) + ip_address_or_dns_name = \ + model.extract_ip_address_or_dns_name_from_locally_mounted_remote_share_path(file_path) + + data_webdav_manager = \ + webdavmanager.get_data_webdav_manager(ip_address_or_dns_name) + metadata_webdav_manager = \ + webdavmanager.get_metadata_webdav_manager(ip_address_or_dns_name) + + # Download the preview file, if it exists. + preview_resource = \ + webdavmanager.get_resource_by_resource_key(metadata_webdav_manager, + '/webdav/.Sugar-Metadata/' + filename + '.preview') + preview_path = os.path.dirname(file_path) + '/.Sugar-Metadata/'+ filename + '.preview' + + if preview_resource is not None: + try: + preview_resource.downloadFile(preview_path, + show_progress=False, + filesize=0) + except (WebdavError, socket.error), e: + error_message = USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + logging.warn(error_message) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, + _('Error')) + return False + + # If we manage to reach here, download the data file. + data_resource = \ + webdavmanager.get_resource_by_resource_key(data_webdav_manager, + '/webdav/'+ filename) + try: + data_resource.downloadFile(metadata['uid'], + show_progress=True, + filesize=int(metadata['filesize'])) + return True + except (WebdavError, socket.error), e: + # Delete the downloaded preview file, if it exists. + if os.path.exists(preview_path): + os.unlink(preview_path) + + error_message = USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + logging.warn(error_message) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, + _('Error')) + return False + + file_path = model.get_file(metadata['uid']) if not file_path or not os.path.exists(file_path): logging.warn('Entries without a file cannot be copied.') - self.emit('volume-error', - _('Entries without a file cannot be copied.'), - _('Warning')) - return + error_message = _('Entries without a file cannot be copied.') + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Warning')) + return False + else: + return True + + def _metadata_copy_valid(self, metadata, mount_point): + self._set_bundle_installation_allowed(False) try: - model.copy(self._metadata, mount_point) - except IOError, e: - logging.exception('Error while copying the entry. %s', e.strerror) - self.emit('volume-error', - _('Error while copying the entry. %s') % e.strerror, - _('Error')) + model.copy(metadata, mount_point) + return True + except Exception, e: + logging.exception(e) + error_message = _('Error while copying the entry. %s') % e + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Error')) + return False + finally: + self._set_bundle_installation_allowed(True) + def _metadata_write_valid(self, metadata): + operation = self._get_info_alert_title() + self._set_bundle_installation_allowed(False) -class ClipboardMenu(MenuItem): - __gtype_name__ = 'JournalClipboardMenu' + try: + model.update_only_metadata_and_preview_files_and_return_file_paths(metadata) + return True + except Exception, e: + logging.exception('Error while writing the metadata. %s', e) + error_message = _('Error occurred while %s : %s.') % \ + (operation, e,) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Error')) + return False + finally: + self._set_bundle_installation_allowed(True) - __gsignals__ = { - 'volume-error': (GObject.SignalFlags.RUN_FIRST, None, - ([str, str])), - } + def _set_bundle_installation_allowed(self, allowed): + """ + This method serves only as a "delegating" method. + This has been done to aid easy configurability. + """ + from jarabe.journal.journalactivity import get_journal + journal = get_journal() - def __init__(self, metadata): - MenuItem.__init__(self, _('Clipboard')) + if self._batch_mode: + journal.set_bundle_installation_allowed(allowed) - self._temp_file_path = None - self._metadata = metadata - self.connect('activate', self.__copy_to_clipboard_cb) + def get_number_of_entries_to_operate_upon(self): + return len(self._immutable_metadata_list) - def __copy_to_clipboard_cb(self, menu_item): - file_path = model.get_file(self._metadata['uid']) - if not file_path or not os.path.exists(file_path): - logging.warn('Entries without a file cannot be copied.') - self.emit('volume-error', - _('Entries without a file cannot be copied.'), - _('Warning')) - return + +class BaseCopyMenuItem(MenuItem, ActionItem): + __gtype_name__ = 'JournalBaseCopyMenuItem' + + __gsignals__ = { + 'volume-error': (GObject.SignalFlags.RUN_FIRST, + None, ([str, str])), + } + + def __init__(self, metadata_list, label, show_editing_alert, + show_progress_info_alert, batch_mode, mount_point): + MenuItem.__init__(self, label) + ActionItem.__init__(self, label, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode, + auto_deselect_source_entries=False, + need_to_popup_options=False, + operate_on_deselected_entries=False, + show_not_completed_ops_info=True) + self._mount_point = mount_point + + def get_mount_point(self): + return self._mount_point + + def _get_actionable_signal(self): + return 'activate' + + def _get_editing_alert_title(self): + return _('Copy') + + def _get_editing_alert_message(self, entries_len): + return ngettext('Do you want to copy %d entry to %s?', + 'Do you want to copy %d entries to %s?', + entries_len) % (entries_len, self._label) + + def _get_editing_alert_operation(self): + return _('Copy') + + def _get_info_alert_title(self): + return _('Copying') + + def _operate(self, metadata): + from jarabe.journal.journalactivity import get_mount_point + if(model.is_mount_point_for_locally_mounted_remote_share(get_mount_point())) \ + and (model.is_mount_point_for_school_server(get_mount_point()) == True): + PassphraseDialog(self._proceed_after_receiving_passphrase, metadata) + else: + self._proceed_with_copy(metadata) + + def _proceed_after_receiving_passphrase(self, metadata, passphrase): + if metadata['passphrase'] != passphrase: + error_message = _('Passphrase does not match.') + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Error')) + return False + else: + self._unhide_info_widget_for_single_mode() + GObject.idle_add(self._proceed_with_copy, metadata) + + def _proceed_with_copy(self, metadata): + return NotImplementedError + + def _post_successful_copy(self, metadata, response_id=None): + from jarabe.journal.journalactivity import get_journal, \ + get_mount_point + + if model.is_mount_point_for_locally_mounted_remote_share(get_mount_point()): + successful_downloading_message = None + + if model.is_mount_point_for_school_server(get_mount_point()) == True: + # TRANS: Do not translate the %s. + successful_downloading_message = \ + _('Your file "%s" was correctly downloaded from the School Server.' % metadata['title']) + else: + # TRANS: Do not translate the %s. + successful_downloading_message = \ + _('Your file "%s" was correctly downloaded from the Peer.' % metadata['title']) + + from jarabe.journal.journalactivity import get_journal + get_journal().update_error_alert(self._get_editing_alert_title(), + successful_downloading_message, + self._post_operate_per_metadata_per_action, + metadata) + else: + self._post_operate_per_metadata_per_action(metadata) + + +class VolumeMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, label, mount_point, + show_editing_alert, show_progress_info_alert, + batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, label, + show_editing_alert, + show_progress_info_alert, batch_mode, + mount_point) + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, self._mount_point): + return False + + # This is sync-operation. Thus, call the callback. + self._post_successful_copy(metadata) + + +class ClipboardMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Clipboard'), + show_editing_alert, + show_progress_info_alert, + batch_mode, None) + self._temp_file_path_list = [] + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False clipboard = Gtk.Clipboard() clipboard.set_with_data([('text/uri-list', 0, 0)], self.__clipboard_get_func_cb, - self.__clipboard_clear_func_cb) + self.__clipboard_clear_func_cb, + metadata) - def __clipboard_get_func_cb(self, clipboard, selection_data, info, data): + def __clipboard_get_func_cb(self, clipboard, selection_data, info, + metadata): # Get hold of a reference so the temp file doesn't get deleted - self._temp_file_path = model.get_file(self._metadata['uid']) + self._temp_file_path = model.get_file(metadata['uid']) logging.debug('__clipboard_get_func_cb %r', self._temp_file_path) selection_data.set_uris(['file://' + self._temp_file_path]) - def __clipboard_clear_func_cb(self, clipboard, data): + def __clipboard_clear_func_cb(self, clipboard, metadata): # Release and delete the temp file self._temp_file_path = None + # This is async-operation; and this is the ending point. + self._post_successful_copy(metadata) + + +class DocumentsMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Documents'), + show_editing_alert, + show_progress_info_alert, + batch_mode, + model.get_documents_path()) + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, + model.get_documents_path()): + return False + + # This is sync-operation. Call the post-operation now. + self._post_successful_copy(metadata) + + +class LocalSharesMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Local Shares'), + show_editing_alert, + show_progress_info_alert, + batch_mode, + model.LOCAL_SHARES_MOUNT_POINT) + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False + + # Attach the filesize. + file_path = model.get_file(metadata['uid']) + metadata['filesize'] = os.stat(file_path).st_size + + # Attach the current mount-point. + from jarabe.journal.journalactivity import get_mount_point + metadata['mountpoint'] = get_mount_point() + + if not self._metadata_write_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, + model.LOCAL_SHARES_MOUNT_POINT): + return False + + # This is sync-operation. Call the post-operation now. + self._post_successful_copy(metadata) + + +class SchoolServerMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('School Server'), + show_editing_alert, + show_progress_info_alert, + batch_mode, + model.WEBDAV_MOUNT_POINT + model.SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME) + + def _operate(self, metadata): + if not self._file_path_valid(metadata): + return False + + # If the entry is copyable, proceed with asking the + # upload-passphrase. + PassphraseDialog(self._proceed_after_receiving_passphrase, metadata) + + def _proceed_after_receiving_passphrase(self, metadata, passphrase): + self._unhide_info_widget_for_single_mode() + GObject.idle_add(self._proceed_with_uploading, metadata, + passphrase) + + def _proceed_with_uploading(self, metadata, passphrase): + # + # Attach the passphrase. + metadata['passphrase'] = passphrase + + # Attach the filesize. + file_path = model.get_file(metadata['uid']) + metadata['filesize'] = os.stat(file_path).st_size + + # Attach the current mount-point. + from jarabe.journal.journalactivity import get_mount_point, \ + get_journal + metadata['mountpoint'] = get_mount_point() + + # Attach the info of the uploader. + from jarabe.model.buddy import get_owner_instance + metadata['uploader-nick'] = get_owner_instance().props.nick + metadata['uploader-serial'] = misc.get_xo_serial() + + if not self._metadata_write_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, + model.WEBDAV_MOUNT_POINT + model.SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME): + return False + + # TRANS: Do not translate the %s. + successful_uploading_message = \ + _('Your file "%s" was correctly uploaded to the School Server.' % metadata['title']) + get_journal().update_error_alert(self._get_editing_alert_title(), + successful_uploading_message, + self._post_successful_copy, + metadata) + class FriendsMenu(Gtk.Menu): __gtype_name__ = 'JournalFriendsMenu' @@ -401,3 +1152,116 @@ class BuddyPalette(Palette): icon=buddy_icon) # TODO: Support actions on buddies, like make friend, invite, etc. + + +class CopyMenuHelper(Gtk.Menu): + __gtype_name__ = 'JournalCopyMenuHelper' + + __gsignals__ = { + 'volume-error': (GObject.SignalFlags.RUN_FIRST, + None, ([str, str])), + } + + def insert_copy_to_menu_items(self, menu, metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode): + self._metadata_list = metadata_list + + clipboard_menu = ClipboardMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + clipboard_menu.set_image(Icon(icon_name='toolbar-edit', + icon_size=Gtk.IconSize.MENU)) + clipboard_menu.connect('volume-error', self.__volume_error_cb) + menu.append(clipboard_menu) + clipboard_menu.show() + + from jarabe.journal.journalactivity import get_mount_point + + if get_mount_point() != model.get_documents_path(): + documents_menu = DocumentsMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + documents_menu.set_image(Icon(icon_name='user-documents', + icon_size=Gtk.IconSize.MENU)) + documents_menu.connect('volume-error', self.__volume_error_cb) + menu.append(documents_menu) + documents_menu.show() + + if (model.is_school_server_present()) and \ + (not model.is_mount_point_for_locally_mounted_remote_share(get_mount_point())): + documents_menu = SchoolServerMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + documents_menu.set_image(Icon(icon_name='school-server', + icon_size=Gtk.IconSize.MENU)) + documents_menu.connect('volume-error', self.__volume_error_cb) + menu.append(documents_menu) + documents_menu.show() + + if (model.is_peer_to_peer_sharing_available()) and \ + (get_mount_point() != model.LOCAL_SHARES_MOUNT_POINT): + local_shares_menu = LocalSharesMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + local_shares_menu.set_image(Icon(icon_name='emblem-neighborhood-shared', + icon_size=Gtk.IconSize.MENU)) + local_shares_menu.connect('volume-error', self.__volume_error_cb) + menu.append(local_shares_menu) + local_shares_menu.show() + + if get_mount_point() != '/': + client = GConf.Client.get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + journal_menu = VolumeMenu(metadata_list, _('Journal'), '/', + show_editing_alert, + show_progress_info_alert, + batch_mode) + journal_menu.set_image(Icon(icon_name='activity-journal', + xo_color=color, + icon_size=Gtk.IconSize.MENU)) + journal_menu.connect('volume-error', self.__volume_error_cb) + menu.append(journal_menu) + journal_menu.show() + + volume_monitor = Gio.VolumeMonitor.get() + icon_theme = Gtk.IconTheme.get_default() + for mount in volume_monitor.get_mounts(): + if get_mount_point() == mount.get_root().get_path(): + continue + + volume_menu = VolumeMenu(metadata_list, mount.get_name(), + mount.get_root().get_path(), + show_editing_alert, + show_progress_info_alert, + batch_mode) + for name in mount.get_icon().props.names: + if icon_theme.has_icon(name): + volume_menu.set_image(Icon(icon_name=name, + icon_size=Gtk.IconSize.MENU)) + break + + volume_menu.connect('volume-error', self.__volume_error_cb) + menu.insert(volume_menu, -1) + volume_menu.show() + + def __volume_error_cb(self, menu_item, message, severity): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + journal._volume_error_cb(menu_item, message, severity) + + +def get_copy_menu_helper(): + global _copy_menu_helper + if _copy_menu_helper is None: + _copy_menu_helper = CopyMenuHelper() + return _copy_menu_helper + + +def get_current_action_item(): + return _current_action_item diff --git a/src/jarabe/journal/volumestoolbar.py b/src/jarabe/journal/volumestoolbar.py index 1fc368e..ed2a0a3 100644 --- a/src/jarabe/journal/volumestoolbar.py +++ b/src/jarabe/journal/volumestoolbar.py @@ -37,11 +37,14 @@ from sugar3.graphics.xocolor import XoColor from sugar3 import env from jarabe.journal import model -from jarabe.view.palettes import VolumePalette +from jarabe.view.palettes import VolumePalette, RemoteSharePalette _JOURNAL_0_METADATA_DIR = '.olpc.store' +SHARE_TYPE_PEER = 1 +SHARE_TYPE_SCHOOL_SERVER = 2 + def _get_id(document): """Get the ID for the document in the xapian database.""" @@ -193,6 +196,17 @@ class VolumesToolbar(Gtk.Toolbar): def _set_up_volumes(self): self._set_up_documents_button() + if model.is_peer_to_peer_sharing_available(): + self._set_up_local_shares_button() + + client = GConf.Client.get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + + if model.is_school_server_present(): + self._add_remote_share_button(_('School-Server Shares'), + model.SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME, + color, SHARE_TYPE_SCHOOL_SERVER) + volume_monitor = Gio.VolumeMonitor.get() self._mount_added_hid = volume_monitor.connect('mount-added', self.__mount_added_cb) @@ -202,12 +216,11 @@ class VolumesToolbar(Gtk.Toolbar): for mount in volume_monitor.get_mounts(): self._add_button(mount) - def _set_up_documents_button(self): - documents_path = model.get_documents_path() - if documents_path is not None: - button = DocumentsButton(documents_path) + def _set_up_directory_button(self, dir_path, icon_name, label_text): + if dir_path is not None: + button = DirectoryButton(dir_path, icon_name) button.props.group = self._volume_buttons[0] - label = glib.markup_escape_text(_('Documents')) + label = glib.markup_escape_text(label_text) button.set_palette(Palette(label)) button.connect('toggled', self._button_toggled_cb) button.show() @@ -217,6 +230,44 @@ class VolumesToolbar(Gtk.Toolbar): self._volume_buttons.append(button) self.show() + def _set_up_documents_button(self): + documents_path = model.get_documents_path() + self._set_up_directory_button(documents_path, + 'user-documents', + _('Documents')) + + def _set_up_local_shares_button(self): + local_shares_path = model.LOCAL_SHARES_MOUNT_POINT + self._set_up_directory_button(local_shares_path, + 'emblem-neighborhood-shared', + _('Local Shares')) + + def _add_remote_share_button(self, primary_text, + ip_address_or_dns_name, color, + share_type): + button = RemoteSharesButton(primary_text, ip_address_or_dns_name, + color, share_type) + button._share_type = share_type + button.props.group = self._volume_buttons[0] + + show_unmount_option = None + if share_type == SHARE_TYPE_PEER: + show_unmount_option = True + else: + show_unmount_option = False + button.set_palette(RemoteSharePalette(primary_text, + ip_address_or_dns_name, button, + show_unmount_option)) + button.connect('toggled', self._button_toggled_cb) + button.show() + + position = self.get_item_index(self._volume_buttons[-1]) + 1 + self.insert(button, position) + self._volume_buttons.append(button) + self.show() + + return button + def __mount_added_cb(self, volume_monitor, mount): self._add_button(mount) @@ -247,10 +298,26 @@ class VolumesToolbar(Gtk.Toolbar): def __volume_error_cb(self, button, strerror, severity): self.emit('volume-error', strerror, severity) - def _button_toggled_cb(self, button): - if button.props.active: + def _button_toggled_cb(self, button, force_toggle=False): + if button.props.active or force_toggle: + button.set_active(True) + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.hide_alert() + journal.get_list_view()._selected_entries = 0 + journal.switch_to_editing_mode(False) + journal.get_list_view().inhibit_refresh(False) + self.emit('volume-changed', button.mount_point) + def _unmount_activated_cb(self, menu_item, mount): + logging.debug('VolumesToolbar._unmount_activated_cb: %r', mount) + mount.unmount(self.__unmount_cb) + + def __unmount_cb(self, source, result): + logging.debug('__unmount_cb %r %r', source, result) + def _get_button_for_mount(self, mount): mount_point = mount.get_root().get_path() for button in self.get_children(): @@ -259,6 +326,13 @@ class VolumesToolbar(Gtk.Toolbar): logging.error('Couldnt find button with mount_point %r', mount_point) return None + def _get_button_for_mount_point(self, mount_point): + for button in self.get_children(): + if button.mount_point == mount_point: + return button + logging.error('Couldnt find button with mount_point %r', mount_point) + return None + def _remove_button(self, mount): button = self._get_button_for_mount(mount) self._volume_buttons.remove(button) @@ -268,10 +342,33 @@ class VolumesToolbar(Gtk.Toolbar): if len(self.get_children()) < 2: self.hide() + def _remove_remote_share_button(self, ip_address_or_dns_name): + for button in self.get_children(): + if type(button) == RemoteSharesButton and \ + button.mount_point == (model.WEBDAV_MOUNT_POINT + ip_address_or_dns_name): + self._volume_buttons.remove(button) + self.remove(button) + + from jarabe.journal.webdavmanager import \ + unmount_share_from_backend + unmount_share_from_backend(ip_address_or_dns_name) + + self.get_children()[0].props.active = True + + if len(self.get_children()) < 2: + self.hide() + break; + def set_active_volume(self, mount): button = self._get_button_for_mount(mount) button.props.active = True + def get_journal_button(self): + return self._volume_buttons[0] + + def get_button_toggled_cb(self): + return self._button_toggled_cb + class BaseButton(RadioToolButton): __gsignals__ = { @@ -291,23 +388,36 @@ class BaseButton(RadioToolButton): def _drag_data_received_cb(self, widget, drag_context, x, y, selection_data, info, timestamp): + # Disallow copying to mounted-shares for peers. + if (model.is_mount_point_for_locally_mounted_remote_share(self.mount_point)) and \ + (model.is_mount_point_for_peer_share(self.mount_point)): + from jarabe.journal.journalactivity import get_journal + + journal = get_journal() + journal._show_alert(_('Entries cannot be copied to Peer-Shares.'), _('Error')) + return + object_id = selection_data.data metadata = model.get(object_id) - file_path = model.get_file(metadata['uid']) - if not file_path or not os.path.exists(file_path): - logging.warn('Entries without a file cannot be copied.') - self.emit('volume-error', - _('Entries without a file cannot be copied.'), - _('Warning')) - return - try: - model.copy(metadata, self.mount_point) - except IOError, e: - logging.exception('Error while copying the entry. %s', e.strerror) - self.emit('volume-error', - _('Error while copying the entry. %s') % e.strerror, - _('Error')) + from jarabe.journal.palettes import CopyMenu, get_copy_menu_helper + copy_menu_helper = get_copy_menu_helper() + + dummy_copy_menu = CopyMenu() + copy_menu_helper.insert_copy_to_menu_items(dummy_copy_menu, + [metadata], + False, + False, + False) + + # Now, activate the menuitem, whose mount-point matches the + # mount-point of the button, upon whom the item has been + # dragged. + children_menu_items = dummy_copy_menu.get_children() + for child in children_menu_items: + if child.get_mount_point() == self.mount_point: + child.activate() + return class VolumeButton(BaseButton): @@ -386,13 +496,35 @@ class JournalButtonPalette(Palette): {'free_space': free_space / (1024 * 1024)} -class DocumentsButton(BaseButton): +class DirectoryButton(BaseButton): - def __init__(self, documents_path): - BaseButton.__init__(self, mount_point=documents_path) + def __init__(self, dir_path, icon_name): + BaseButton.__init__(self, mount_point=dir_path) + self._mount = dir_path - self.props.icon_name = 'user-documents' + self.props.icon_name = icon_name client = GConf.Client.get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) self.props.xo_color = color + + +class RemoteSharesButton(BaseButton): + + def __init__(self, primary_text, ip_address_or_dns_name, color, + share_type): + BaseButton.__init__(self, mount_point=(model.WEBDAV_MOUNT_POINT + ip_address_or_dns_name)) + + self._primary_text = primary_text + self._ip_address_or_dns_name = ip_address_or_dns_name + + if share_type == SHARE_TYPE_PEER: + self.props.icon_name = 'emblem-neighborhood-shared' + elif share_type == SHARE_TYPE_SCHOOL_SERVER: + self.props.icon_name = 'school-server' + self.props.xo_color = color + + def create_palette(self): + palette = RemoteSharePalette(self._primary_text, self._ip_address_or_dns_name, + self, True) + return palette diff --git a/src/jarabe/journal/webdavmanager.py b/src/jarabe/journal/webdavmanager.py new file mode 100644 index 0000000..6936239 --- /dev/null +++ b/src/jarabe/journal/webdavmanager.py @@ -0,0 +1,312 @@ +from gettext import gettext as _ + +from gi.repository import GObject + +import logging +import os +import sys + +import simplejson +import shutil + +from webdav.Connection import AuthorizationError, WebdavError +from webdav.WebdavClient import CollectionStorer + +def get_key_from_resource(resource): + return resource.path + +class WebDavUrlManager(GObject.GObject): + """ + This class holds all data, relevant to a WebDavUrl. + + One thing must be noted, that a valid WebDavUrl is the one which + may contain zero or more resources (files), or zero or more + collections (directories). + + Thus, following are valid WebDavUrls :: + + dav://1.2.3.4/webdav + dav://1.2.3.4/webdav/dir_1 + dav://1.2.3.4/webdav/dir_1/dir_2 + + but following are not :: + + dav://1.2.3.4/webdav/a.txt + dav://1.2.3.4/webdav/dir_1/b.jpg + dav://1.2.3.4/webdab/dir_1/dir_2/c.avi + """ + + def __init__(self, WebDavUrl, username, password): + self._WebDavUrl = WebDavUrl + self._username = username + self._password = password + + def _get_key_from_resource(self, resource): + return resource.path.encode(sys.getfilesystemencoding()) + + def _get_number_of_collections(self): + return len(self._remote_webdav_share_collections) + + def _get_root(self): + return self._root + + def _get_resources_dict(self): + return self._remote_webdav_share_resources + + def _get_collections_dict(self): + return self._remote_webdav_share_collections + + def _get_resource_by_key(self, key): + if key in self._remote_webdav_share_resources.keys(): + return self._remote_webdav_share_resources[key]['resource'] + return None + + def _add_or_replace_resource_by_key(self, key, resource): + self._remote_webdav_share_resources[key] = {} + self._remote_webdav_share_resources[key]['resource'] = resource + + def _get_metadata_list(self): + metadata_list = [] + for key in self._remote_webdav_share_resources.keys(): + metadata_list.append(self._remote_webdav_share_resources[key]['metadata']) + return metadata_list + + def _get_live_properties(self, resource_key): + resource_container = self._remote_webdav_share_resources[resource_key] + return resource_container['webdav-properties'] + + def _fetch_resources_and_collections(self): + webdavConnection = CollectionStorer(self._WebDavUrl, validateResourceNames=False) + self._root = webdavConnection + + authFailures = 0 + while authFailures < 2: + try: + self._remote_webdav_share_resources = {} + self._remote_webdav_share_collections = {} + + try: + self._collection_contents = webdavConnection.getCollectionContents() + for resource, properties in self._collection_contents: + try: + key = self._get_key_from_resource(resource) + selected_dict = None + + if properties.getResourceType() == 'resource': + selected_dict = self._remote_webdav_share_resources + else: + selected_dict = self._remote_webdav_share_collections + + selected_dict[key] = {} + selected_dict[key]['resource'] = resource + selected_dict[key]['webdav-properties'] = properties + except UnicodeEncodeError: + print("Cannot encode resource path or properties.") + + return True + + except WebdavError, e: + # Note that, we need to deal with all errors, + # except "AuthorizationError", as that is not + # really an error from our perspective. + if not type(e) == AuthorizationError: + from jarabe.journal.journalactivity import get_journal + + from jarabe.journal.palettes import USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + error_message = USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + get_journal()._volume_error_cb(None, error_message,_('Error')) + + # Re-raise this error. + # Note that since this is not an + # "AuthorizationError", this will not be caught + # by the outer except-block. Instead, it will + # navigate all the way back up, and will report + # the error in the enclosing except block. + raise e + + else: + # If this indeed is an Authorization Error, + # re-raise it, so that it is caught by the outer + # "except" block. + raise e + + + except AuthorizationError, e: + if self._username is None or self._password is None: + raise Exception("WebDav username or password is None. Please specify appropriate values.") + + if e.authType == "Basic": + webdavConnection.connection.addBasicAuthorization(self._username, self._password) + elif e.authType == "Digest": + info = parseDigestAuthInfo(e.authInfo) + webdavConnection.connection.addDigestAuthorization(self._username, self._password, realm=info["realm"], qop=info["qop"], nonce=info["nonce"]) + else: + raise + authFailures += 1 + + return False + +webdav_manager = {} + +def get_data_webdav_manager(ip_address_or_dns_name): + return webdav_manager[ip_address_or_dns_name]['data'] + + +def get_metadata_webdav_manager(ip_address_or_dns_name): + return webdav_manager[ip_address_or_dns_name]['metadata'] + + +def get_resource_by_resource_key(root_webdav, key): + resources_dict = root_webdav._get_resources_dict() + if key in resources_dict.keys(): + resource_dict = resources_dict[key] + resource = resource_dict['resource'] + return resource + return None + + +def add_resource_by_resource_key(root_webdav, key, + content_file_path): + root = root_webdav._get_root() + + resource = root.addResource(key) + + # Procure the resource-lock. + lockToken = resource.lock('olpc') + + input_stream = open(content_file_path) + + # Now, upload the data; but it's necessary to enclose this in a + # try-except-finally block here, since we need to close the + # input-stream, whatever may happen. + try: + resource.uploadFile(input_stream, lockToken) + root_webdav._add_or_replace_resource_by_key(key, resource) + except Exception, e: + logging.exception(e) + resource.delete(lockToken) + raise e + else: + resource.unlock(lockToken) + finally: + input_stream.close() + + +def get_remote_webdav_share_metadata(ip_address_or_dns_name): + protocol = 'davs://' + root_webdav_url = '/webdav' + complete_root_url = protocol + ip_address_or_dns_name + root_webdav_url + + root_webdav = WebDavUrlManager(complete_root_url, 'test', 'olpc') + if root_webdav._fetch_resources_and_collections() is False: + # Return empty metadata list. + return [] + + # Keep reference to the "WebDavUrlManager", keyed by IP-Address. + global webdav_manager + webdav_manager[ip_address_or_dns_name] = {} + webdav_manager[ip_address_or_dns_name]['data'] = root_webdav + + + # Assert that the number of collections is only one at this url + # (i.e. only ".Sugar-Metadata" is present). + assert root_webdav._get_number_of_collections() == 1 + + root_sugar_metadata_url = root_webdav_url + '/.Sugar-Metadata' + + complete_root_sugar_metadata_url = protocol + ip_address_or_dns_name + root_sugar_metadata_url + root_webdav_sugar_metadata = WebDavUrlManager(complete_root_sugar_metadata_url, 'test', 'olpc') + if root_webdav_sugar_metadata._fetch_resources_and_collections() is False: + # Return empty metadata list. + return [] + + webdav_manager[ip_address_or_dns_name]['metadata'] = root_webdav_sugar_metadata + + # assert that the number of collections is zero at this url. + assert root_webdav_sugar_metadata._get_number_of_collections() == 0 + + # Now. associate sugar-metadata with each of the "root-webdav" + # resource. + root_webdav_resources = root_webdav._get_resources_dict() + root_webdav_sugar_metadata_resources = root_webdav_sugar_metadata._get_resources_dict() + + # Prepare the metadata-download folder. + downloaded_data_root_dir = '/tmp/' + ip_address_or_dns_name + downloaded_metadata_file_dir = downloaded_data_root_dir + '/.Sugar-Metadata' + if os.path.isdir(downloaded_data_root_dir): + shutil.rmtree(downloaded_data_root_dir) + os.makedirs(downloaded_metadata_file_dir) + + metadata_list = [] + + # Note that the presence of a resource in the metadata directory, + # is the only assurance of the entry (and its constituents) being + # present in entirety. Thus, always proceed taking the metadata as + # the "key". + for root_webdav_sugar_metadata_resource_name in root_webdav_sugar_metadata_resources.keys(): + """ + root_webdav_sugar_metadata_resource_name is of the type :: + + /webdav/.Sugar-Metadata/a.txt.metadata, OR + /webdav/.Sugar-Metadata/a.txt.preview + """ + + # If this is a "preview" resource, continue forward, as we only + # want the metadata list. The "preview" resources are anyways + # already present in the manager DS. + if root_webdav_sugar_metadata_resource_name.endswith('.preview'): + continue + + split_tokens_array = root_webdav_sugar_metadata_resource_name.split('/') + + # This will provide us with "a.txt.metadata" + sugar_metadata_basename = split_tokens_array[len(split_tokens_array) - 1] + + # This will provide us with "a.txt" + basename = sugar_metadata_basename[0:sugar_metadata_basename.index('.metadata')] + + downloaded_metadata_file_path = downloaded_metadata_file_dir + '/' + sugar_metadata_basename + metadata_resource = \ + root_webdav_sugar_metadata._get_resource_by_key(root_webdav_sugar_metadata_resource_name) + metadata_resource.downloadFile(downloaded_metadata_file_path) + + + # We need to download the preview-file as well at this stage, + # so that it can be shown in the expanded entry. + downloaded_preview_file_path = downloaded_metadata_file_dir + \ + '/' + basename + '.preview' + root_webdav_sugar_preview_resource_name = \ + root_webdav_sugar_metadata_resource_name[0:root_webdav_sugar_metadata_resource_name.index('.metadata')] + \ + '.preview' + preview_resource = \ + root_webdav_sugar_metadata._get_resource_by_key(root_webdav_sugar_preview_resource_name) + if preview_resource is not None: + preview_resource.downloadFile(downloaded_preview_file_path) + + + file_pointer = open(downloaded_metadata_file_path) + metadata = eval(file_pointer.read()) + file_pointer.close() + + # Fill in the missing metadata properties. + # Note that the file is not physically present. + metadata['uid'] = downloaded_data_root_dir + '/' + basename + metadata['creation_time'] = metadata['timestamp'] + + # Now, write this to the metadata-file, so that + # webdav-properties get gelled into sugar-metadata. + file_pointer = open(downloaded_metadata_file_path, 'w') + file_pointer.write(simplejson.dumps(metadata)) + file_pointer.close() + + metadata_list.append(metadata) + + return metadata_list + + +def is_remote_webdav_loaded(ip_address_or_dns_name): + return ip_address_or_dns_name in webdav_manager.keys() + + +def unmount_share_from_backend(ip_address_or_dns_name): + del webdav_manager[ip_address_or_dns_name] diff --git a/src/jarabe/model/neighborhood.py b/src/jarabe/model/neighborhood.py index 85c35c9..bf9cbf3 100644 --- a/src/jarabe/model/neighborhood.py +++ b/src/jarabe/model/neighborhood.py @@ -929,6 +929,9 @@ class Neighborhood(GObject.GObject): if 'key' in properties: buddy.props.key = properties['key'] + if 'ip4-address' in properties: + buddy.props.ip_address = properties['ip4-address'] + nick_key = CONNECTION_INTERFACE_ALIASING + '/alias' if nick_key in properties: buddy.props.nick = properties[nick_key] diff --git a/src/jarabe/view/buddymenu.py b/src/jarabe/view/buddymenu.py index d17f4ff..93790fd 100644 --- a/src/jarabe/view/buddymenu.py +++ b/src/jarabe/view/buddymenu.py @@ -73,6 +73,18 @@ class BuddyMenu(Palette): self.menu.append(menu_item) menu_item.show() + remote_share_menu_item = None + from jarabe.journal import webdavmanager + if not webdavmanager.is_remote_webdav_loaded(self._buddy.props.ip_address): + remote_share_menu_item = MenuItem(_('Access Share'), 'list-add') + remote_share_menu_item.connect('activate', self._access_share_cb) + else: + remote_share_menu_item = MenuItem(_('Unmount Share'), 'list-remove') + remote_share_menu_item.connect('activate', self.__unmount_cb) + + self.menu.append(remote_share_menu_item) + remote_share_menu_item.show() + self._invite_menu = MenuItem('') self._invite_menu.connect('activate', self._invite_friend_cb) self.menu.append(self._invite_menu) diff --git a/src/jarabe/view/palettes.py b/src/jarabe/view/palettes.py index 10844ea..4ccf8bc 100644 --- a/src/jarabe/view/palettes.py +++ b/src/jarabe/view/palettes.py @@ -268,3 +268,54 @@ class VolumePalette(Palette): self._progress_bar.props.fraction = fraction self._free_space_label.props.label = _('%(free_space)d MB Free') % \ {'free_space': free_space / (1024 * 1024)} + + +class RemoteSharePalette(Palette): + def __init__(self, primary_text, ip_address_or_dns_name, button, + show_unmount_option): + Palette.__init__(self, label=primary_text) + self._button = button + self._ip_address_or_dns_name = ip_address_or_dns_name + + self.props.secondary_text = \ + glib.markup_escape_text(self._ip_address_or_dns_name) + + vbox = Gtk.VBox() + self.set_content(vbox) + vbox.show() + + self.connect('popup', self.__popup_cb) + + menu_item = PaletteMenuItem(pgettext('Share', _('Reload'))) + icon = Icon(icon_name='system-restart', icon_size=Gtk.IconSize.MENU) + menu_item.set_image(icon) + icon.show() + + menu_item.connect('activate', self.__reload_remote_share) + vbox.add(menu_item) + menu_item.show() + + + if show_unmount_option == True: + menu_item = PaletteMenuItem(pgettext('Share', 'Unmount')) + icon = Icon(icon_name='media-eject', icon_size=gtk.ICON_SIZE_MENU) + menu_item.set_image(icon) + icon.show() + + menu_item.connect('activate', self.__unmount_activate_cb) + vbox.add(menu_item) + menu_item.show() + + def __reload_remote_share(self, menu_item): + from jarabe.journal.journalactivity import get_journal + get_journal().hide_alert() + get_journal().get_list_view().refresh() + + def __unmount_activate_cb(self, menu_item): + from jarabe.journal.journalactivity import get_journal + + singleton_volumes_toolbar = get_journal().get_volumes_toolbar() + singleton_volumes_toolbar._remove_remote_share_button(self._ip_address_or_dns_name) + + def __popup_cb(self, palette): + pass |