Note that this patch must be appled in full, and not over version-1 patch. Also note that this patch MUST be applied after the applying of patch at : http://patchwork.sugarlabs.org/patch/1157/
== This code has been written almost exclusively by Martin Abente. == Design discussions at : http://wiki.sugarlabs.org/go/Features/Multi_selection == Screenshots at : http://wiki.sugarlabs.org/go/Features/Multi_selection_screenshots == Martin's work's video at : http://www.sugarlabs.org/~tch/journal2.mpeg Following are the changes/enhancements from Martin's work : a. More copy-to options :: Clipboard, Documents (in addition to mounted drives). b. After entries are copied to another location, both - the source and the target - entries are de-selected automatically, without the user explicitly have to de-select them all manually. c. There has been a progress bar added for batch-operations. Codewise, effore has been put to have maximum code-reuse; and mimimal code-duplication, as most of the operation-parts are same, irrespective of the operation performed. ================================= CHANGELOG ================================= Changes of version-2 over version-1 ----------------------------------- Fixed the following : a. Let's say, there is a saved instance of "Ruler" activity in the journal, among other possible entries. ("Ruler" is not a special one. In fact, any activity which in single-mode would display "Entries without a file cannot be copied" would work). b. Do "Select All". c. When "Ruler" gets selected, the message "Error: Entries without a file cannot be copied" is displayed (at the select-all stage). EXPECTED BEHAVIOUR :: a. This message should not be displayed at the time of selection. Rather, it should be displayed at the time of "Copy-all" operation. src/jarabe/journal/journalactivity.py | 135 ++++++++- src/jarabe/journal/journaltoolbox.py | 165 ++++++++++- src/jarabe/journal/listmodel.py | 13 + src/jarabe/journal/listview.py | 48 +++ src/jarabe/journal/model.py | 13 +- src/jarabe/journal/palettes.py | 574 +++++++++++++++++++++++++++------ 6 files changed, 833 insertions(+), 115 deletions(-) diff --git a/src/jarabe/journal/journalactivity.py b/src/jarabe/journal/journalactivity.py index 8cafef0..6f03a73 100644 --- a/src/jarabe/journal/journalactivity.py +++ b/src/jarabe/journal/journalactivity.py @@ -1,5 +1,9 @@ # Copyright (C) 2006, Red Hat, Inc. # Copyright (C) 2007, One Laptop Per Child +# Copyright (C) 2012, Walter Bender <[email protected]> +# Copyright (C) 2012, Gonzalo Odiard <[email protected]> +# Copyright (C) 2012, Martin Abente <[email protected]> +# Copyright (C) 2012, Ajay Garg <[email protected]> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,15 +21,18 @@ import logging from gettext import gettext as _ +from gettext import ngettext import uuid import gtk import dbus import statvfs import os +import gobject from sugar.graphics.window import Window -from sugar.graphics.alert import ErrorAlert +from sugar.graphics.alert import Alert, ErrorAlert +from sugar.graphics.icon import Icon from sugar.bundle.bundle import ZipExtractException, RegistrationException from sugar import env @@ -34,7 +41,9 @@ from sugar import wm 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 @@ -53,6 +62,7 @@ _SPACE_TRESHOLD = 52428800 _BUNDLE_ID = 'org.laptop.JournalActivity' _journal = None +_mount_point = None class JournalActivityDBusService(dbus.service.Object): @@ -119,8 +129,15 @@ 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._editing_alert = None + self._info_alert = None + self._selected_entries = [] + + set_mount_point('/') self._setup_main_view() self._setup_secondary_view() @@ -184,7 +201,7 @@ class JournalActivity(JournalWindow): search_toolbar = self._main_toolbox.search_toolbar search_toolbar.connect('query-changed', self._query_changed_cb) search_toolbar.set_mount_point('/') - self._mount_point = '/' + set_mount_point('/') def _setup_secondary_view(self): self._secondary_view = gtk.VBox() @@ -217,9 +234,13 @@ class JournalActivity(JournalWindow): self.show_main_view() 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: + toolbox = EditToolbox() + else: + toolbox = self._main_toolbox + + self.set_toolbar_box(toolbox) + toolbox.show() if self.canvas != self._main_view: self.set_canvas(self._main_view) @@ -254,7 +275,7 @@ class JournalActivity(JournalWindow): def __volume_changed_cb(self, volume_toolbar, mount_point): logging.debug('Selected volume: %r.', mount_point) self._main_toolbox.search_toolbar.set_mount_point(mount_point) - self._mount_point = mount_point + set_mount_point(mount_point) self._main_toolbox.set_current_toolbar(0) def __model_created_cb(self, sender, **kwargs): @@ -364,8 +385,98 @@ class JournalActivity(JournalWindow): self.show_main_view() self.search_grab_focus() - def get_mount_point(self): - return self._mount_point + 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.show_main_view() + elif (not switch) and (self._editing_mode): + self._editing_mode = False + self.show_main_view() + + def get_list_view(self): + return self._list_view + + def remove_editing_alert(self): + if self._editing_alert is not None: + self.remove_alert(self._editing_alert) + self._editing_alert = None + + def add_editing_alert(self, widget_clicked, title, message, operation, + callback): + cancel_icon = Icon(icon_name='dialog-cancel') + ok_icon = Icon(icon_name='dialog-ok') + + alert = Alert() + alert.props.title = title + alert.props.msg = message + alert.add_button(gtk.RESPONSE_CANCEL, _('Cancel'), cancel_icon) + alert.add_button(gtk.RESPONSE_OK, operation, ok_icon) + alert.connect('response', self.__check_for_action, callback) + alert.show() + + self.remove_editing_alert() + + self._editing_alert = alert + self.add_alert(alert) + + def __check_for_action(self, alert, response_id, callback): + self.remove_editing_alert() + if response_id == gtk.RESPONSE_OK: + gobject.idle_add(callback, None) + + def remove_info_alert(self): + if self._info_alert is not None: + self.remove_alert(self._info_alert) + logging.debug('alert removed') + self._info_alert = None + + def add_info_alert(self, button, title, message, show_skip_options, + callback, data): + skip_icon = Icon(icon_name='dialog-cancel') + skip_all_icon = Icon(icon_name='dialog-cancel') + + alert = Alert() + alert.props.title = title + alert.props.msg = message + + if show_skip_options: + alert.add_button(gtk.RESPONSE_CANCEL, _('OK'), skip_icon) + + # Let the user explicitly see each message of the + # non-operatable entry. + #alert.add_button(gtk.RESPONSE_OK, _('Do not notify again'), skip_all_icon) + + alert.connect('response', self.__check_for_skip_action, + callback, data) + + alert.show() + self.remove_info_alert() + self._info_alert = alert + + if show_skip_options: + self.add_alert(alert) + else: + self.add_alert_and_callback(alert, callback, data) + + def __check_for_skip_action(self, alert, response_id, callback, + metadata): + self.remove_editing_alert() + if response_id == gtk.RESPONSE_OK: + gobject.idle_add(callback, True, metadata) + elif response_id == gtk.RESPONSE_CANCEL: + gobject.idle_add(callback, False, metadata) + + 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) + if metadata.get('selected', '0') == selected_state: + metadata_list.append(metadata) + + return metadata_list def get_journal(): @@ -378,3 +489,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 2aa4153..ae107f8 100644 --- a/src/jarabe/journal/journaltoolbox.py +++ b/src/jarabe/journal/journaltoolbox.py @@ -1,5 +1,9 @@ # Copyright (C) 2007, One Laptop Per Child # Copyright (C) 2009, Walter Bender +# Copyright (C) 2012, Walter Bender <[email protected]> +# Copyright (C) 2012, Gonzalo Odiard <[email protected]> +# Copyright (C) 2012, Martin Abente <[email protected]> +# Copyright (C) 2012, Ajay Garg <[email protected]> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,6 +20,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 @@ -43,8 +48,7 @@ from sugar import mime from jarabe.model import bundleregistry 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 palettes _AUTOSEARCH_TIMEOUT = 1000 @@ -63,6 +67,8 @@ _ACTION_MY_FRIENDS = 1 _ACTION_MY_CLASS = 2 +COPY_MENU_HELPER = palettes.get_copy_menu_helper() + class MainToolbox(Toolbox): def __init__(self): Toolbox.__init__(self) @@ -527,6 +533,161 @@ class EntryToolbar(gtk.Toolbar): menu_item.show() +class EditToolbox(Toolbox): + def __init__(self): + Toolbox.__init__(self) + + self.edit_toolbar = EditToolbar() + self.add_toolbar('', self.edit_toolbar) + self.edit_toolbar.show() + + +class EditToolbar(gtk.Toolbar): + def __init__(self): + gtk.Toolbar.__init__(self) + + self.add(SelectNoneButton()) + self.add(SelectAllButton()) + self.add(gtk.SeparatorToolItem()) + self.add(BatchEraseButton()) + self.add(BatchCopyButton()) + + self.show_all() + + +class SelectNoneButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'select-none') + palettes.ActionItem.__init__(self, '', None, + show_editing_alert=False, + show_progress_info_alert=True, + batch_mode=True, + need_to_popup_options=False, + operate_on_deselected_entries=False, + switch_to_normal_mode_after_completion=True) + self.props.tooltip = _('Select none') + + def _get_actionable_signal(self): + return 'clicked' + + def _get_info_alert_title(self): + return _('Deselecting') + + def _operate(self, metadata): + metadata['selected'] = '0' + model.write(metadata, update_mtime=False) + + # This is sync-operation. Thus, call the callback. + self._post_operate_per_metadata_per_action(metadata) + + +class SelectAllButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'select-all') + palettes.ActionItem.__init__(self, '', None, + show_editing_alert=False, + show_progress_info_alert=True, + batch_mode=True, + need_to_popup_options=False, + operate_on_deselected_entries=True, + switch_to_normal_mode_after_completion=False) + self.props.tooltip = _('Select all') + + def _get_actionable_signal(self): + return 'clicked' + + def _get_info_alert_title(self): + return _('Selecting') + + def _operate(self, metadata): + metadata['selected'] = '1' + model.write(metadata, update_mtime=False) + + # 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, '', None, + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True, + need_to_popup_options=False, + operate_on_deselected_entries=False, + switch_to_normal_mode_after_completion=True) + self.props.tooltip = _('Erase') + + 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, '', None, + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True, + need_to_popup_options=True, + operate_on_deselected_entries=False, + switch_to_normal_mode_after_completion=True) + + self.props.tooltip = _('Copy') + + self._metadata_list = 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, + None, + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True) + self.props.palette.popup(immediate=True, state=1) + + + + + + + + + +class EditCopyItem(MenuItem): + __gtype_name__ = 'JournalEditCopyItem' + + def __init__(self, icon_name, text_label, mount_path): + MenuItem.__init__(self, icon_name=icon_name, text_label=text_label) + self.mount_path = mount_path + self.mount_info = text_label + class SortingButton(ToolButton): __gtype_name__ = 'JournalSortingButton' diff --git a/src/jarabe/journal/listmodel.py b/src/jarabe/journal/listmodel.py index 417ff61..a07f897 100644 --- a/src/jarabe/journal/listmodel.py +++ b/src/jarabe/journal/listmodel.py @@ -1,4 +1,8 @@ # Copyright (C) 2009, Tomeu Vizoso +# Copyright (C) 2012, Walter Bender <[email protected]> +# Copyright (C) 2012, Gonzalo Odiard <[email protected]> +# Copyright (C) 2012, Martin Abente <[email protected]> +# Copyright (C) 2012, Ajay Garg <[email protected]> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -54,6 +58,7 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): COLUMN_BUDDY_1 = 9 COLUMN_BUDDY_2 = 10 COLUMN_BUDDY_3 = 11 + COLUMN_SELECT = 12 _COLUMN_TYPES = { COLUMN_UID: str, @@ -68,6 +73,7 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): COLUMN_BUDDY_1: object, COLUMN_BUDDY_3: object, COLUMN_BUDDY_2: object, + COLUMN_SELECT: bool, } _PAGE_SIZE = 10 @@ -198,6 +204,13 @@ class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): self._cached_row.append(None) + # If an entry was already selected, switch to editing mode. + if metadata.get('selected', '0') == '1': + from jarabe.journal.journalactivity import get_journal + get_journal().switch_to_editing_mode(True) + + self._cached_row.append(metadata.get('selected', '0') == '1') + return self._cached_row[column] def on_iter_nth_child(self, iterator, n): diff --git a/src/jarabe/journal/listview.py b/src/jarabe/journal/listview.py index a0ceccc..2aa85ae 100644 --- a/src/jarabe/journal/listview.py +++ b/src/jarabe/journal/listview.py @@ -1,4 +1,8 @@ # Copyright (C) 2009, Tomeu Vizoso +# Copyright (C) 2012, Walter Bender <[email protected]> +# Copyright (C) 2012, Gonzalo Odiard <[email protected]> +# Copyright (C) 2012, Martin Abente <[email protected]> +# Copyright (C) 2012, Ajay Garg <[email protected]> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -98,6 +102,8 @@ class BaseListView(gtk.Bin): self._title_column = None self.sort_column = None self._add_columns() + self._inhibit_refresh = False + self._selected_entries = 0 self.tree_view.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [('text/uri-list', 0, 0), @@ -134,6 +140,18 @@ class BaseListView(gtk.Bin): return object_id.startswith(self._query['mountpoints'][0]) def _add_columns(self): + cell_select = gtk.CellRendererToggle() + cell_select.props.indicator_size = style.zoom(26) + cell_select.props.activatable = True + cell_select.connect('toggled', self.__selected_cb) + + column = gtk.TreeViewColumn() + column.props.sizing = gtk.TREE_VIEW_COLUMN_FIXED + column.props.fixed_width = style.GRID_CELL_SIZE + column.pack_start(cell_select) + column.add_attribute(cell_select, "active", ListModel.COLUMN_SELECT) + self.tree_view.append_column(column) + cell_favorite = CellRendererFavorite(self.tree_view) cell_favorite.connect('clicked', self.__favorite_clicked_cb) @@ -251,6 +269,25 @@ class BaseListView(gtk.Bin): else: cell.props.xo_color = None + def __selected_cb(self, cell, path): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + row = self._model[path] + metadata = model.get(row[ListModel.COLUMN_UID]) + if metadata.get('selected', '0') == '1': + metadata['selected'] = '0' + self._selected_entries = self._selected_entries - 1 + if self._selected_entries < 1: + journal.switch_to_editing_mode(False) + else: + metadata['selected'] = '1' + self._selected_entries = self._selected_entries + 1 + if self._selected_entries > 0: + journal.switch_to_editing_mode(True) + + model.write(metadata, update_mtime=False) + def __favorite_clicked_cb(self, cell, path): row = self._model[path] metadata = model.get(row[ListModel.COLUMN_UID]) @@ -274,9 +311,14 @@ class BaseListView(gtk.Bin): ListModel.COLUMN_TIMESTAMP)) self._query = query_dict + # This refresh is always needed, since the query has changed. self.refresh() def refresh(self): + if not self._inhibit_refresh: + self.proceed_with_refresh() + + def proceed_with_refresh(self): logging.debug('ListView.refresh query %r', self._query) self._stop_progress_bar() @@ -466,6 +508,12 @@ 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 + class ListView(BaseListView): __gtype_name__ = 'JournalListView' diff --git a/src/jarabe/journal/model.py b/src/jarabe/journal/model.py index 5285a7c..39547a4 100644 --- a/src/jarabe/journal/model.py +++ b/src/jarabe/journal/model.py @@ -1,4 +1,8 @@ # Copyright (C) 2007-2011, One Laptop per Child +# Copyright (C) 2012, Walter Bender <[email protected]> +# Copyright (C) 2012, Gonzalo Odiard <[email protected]> +# Copyright (C) 2012, Martin Abente <[email protected]> +# Copyright (C) 2012, Ajay Garg <[email protected]> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -37,7 +41,6 @@ from sugar import dispatch from sugar import mime from sugar import util - DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' DS_DBUS_PATH = '/org/laptop/sugar/DataStore' @@ -45,7 +48,8 @@ DS_DBUS_PATH = '/org/laptop/sugar/DataStore' # Properties the journal cares about. PROPERTIES = ['activity', 'activity_id', 'buddies', 'bundle_id', 'creation_time', 'filesize', 'icon-color', 'keep', 'mime_type', - 'mountpoint', 'mtime', 'progress', 'timestamp', 'title', 'uid'] + 'mountpoint', 'mtime', 'progress', 'timestamp', 'title', + 'uid', 'selected'] MIN_PAGES_TO_CACHE = 3 MAX_PAGES_TO_CACHE = 5 @@ -651,6 +655,11 @@ def write(metadata, file_path='', update_mtime=True, transfer_ownership=True): file_path, transfer_ownership) else: + # HACK: For documents: modify the mount-point + from jarabe.journal.journalactivity import get_mount_point + if get_mount_point() == get_documents_path(): + metadata['mountpoint'] = get_documents_path() + object_id = _write_entry_on_external_device(metadata, file_path) return object_id diff --git a/src/jarabe/journal/palettes.py b/src/jarabe/journal/palettes.py index 27b0b54..2916a14 100644 --- a/src/jarabe/journal/palettes.py +++ b/src/jarabe/journal/palettes.py @@ -1,4 +1,8 @@ # Copyright (C) 2008 One Laptop Per Child +# Copyright (C) 2012, Walter Bender <[email protected]> +# Copyright (C) 2012, Gonzalo Odiard <[email protected]> +# Copyright (C) 2012, Martin Abente <[email protected]> +# Copyright (C) 2012, Ajay Garg <[email protected]> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,6 +19,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 @@ -23,6 +28,9 @@ import gtk import gconf import gio import glib +import time + +from sugar import _sugarext from sugar.graphics import style from sugar.graphics.palette import Palette @@ -39,6 +47,8 @@ from jarabe.journal import model friends_model = friends.get_model() +_copy_menu_helper = None + class BulkOperationDetails(): @@ -129,8 +139,18 @@ class ObjectPalette(Palette): menu_item.set_image(icon) self.menu.append(menu_item) menu_item.show() - copy_menu = CopyMenu(metadata) + copy_menu = CopyMenu() + 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) + copy_menu_helper.connect('volume-error', self.__volume_error_cb) menu_item.set_submenu(copy_menu) if self._metadata['mountpoint'] == '/': @@ -260,156 +280,419 @@ class CopyMenu(gtk.Menu): ([str, str])), } - def __init__(self, metadata): + def __init__(self): gobject.GObject.__init__(self) - self._metadata = metadata - clipboard_menu = ClipboardMenu(self._metadata) - clipboard_menu.set_image(Icon(icon_name='toolbar-edit', - icon_size=gtk.ICON_SIZE_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 + """ - from jarabe.journal import journalactivity - journal_model = journalactivity.get_journal() - if journal_model.get_mount_point() != model.get_documents_path(): - documents_menu = DocumentsMenu(self._metadata) - documents_menu.set_image(Icon(icon_name='user-documents', - icon_size=gtk.ICON_SIZE_MENU)) - documents_menu.connect('volume-error', self.__volume_error_cb) - self.append(documents_menu) - documents_menu.show() + def __init__(self, label, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode, + need_to_popup_options, + operate_on_deselected_entries, + switch_to_normal_mode_after_completion): + gobject.GObject.__init__(self) - 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.ICON_SIZE_MENU)) - journal_menu.connect('volume-error', self.__volume_error_cb) - self.append(journal_menu) - journal_menu.show() + self._label = label + self._metadata_list = metadata_list + self._show_progress_info_alert = show_progress_info_alert + self._batch_mode = batch_mode + self._operate_on_deselected_entries = \ + operate_on_deselected_entries + self._switch_to_normal_mode_after_completion = \ + switch_to_normal_mode_after_completion - volume_monitor = gio.volume_monitor_get() - icon_theme = gtk.icon_theme_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.ICON_SIZE_MENU)) - break - volume_menu.connect('volume-error', self.__volume_error_cb) - self.append(volume_menu) - volume_menu.show() + actionable_signal = self._get_actionable_signal() - def __volume_error_cb(self, menu_item, message, severity): - self.emit('volume-error', message, severity) + if need_to_popup_options: + self.connect(actionable_signal, self._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) + + 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 _fill_and_pop_up_options(self): + """ + 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. + """ + + 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().add_editing_alert(None, title, message, operation, + self._pre_operate_per_action) + + def _get_editing_alert_parameters(self): + """ + Get the alert parameters for widgets that can show editing + alert. + """ + + # For batch-operations, fetch the metadata-list. + if self._batch_mode: + 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_metadata_list(self): + """ + 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. + """ + + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + if self._operate_on_deselected_entries: + return journal.get_metadata_list('0') + else: + return journal.get_metadata_list('1') + + 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 _pre_operate_per_action(self, obj): + """ + This is the stage, just before the FIRST metadata gets into its + processing cycle. + """ + + self._skip_all = False + + # For batch-operations, fetch the metadata list again. + if (self._batch_mode): + self._metadata_list = self._get_metadata_list() + + # Set the initial length of metadata-list. + self._metadata_list_initial_len = len(self._metadata_list) + + # 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. + """ + + # 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) + + # De-select the entry. + metadata['selected'] = '0' + model.write(metadata, update_mtime=False) + + # 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 / %d ) %s') % ( + self._metadata_list_initial_len - current_len, + self._metadata_list_initial_len, metadata['title']) + + from jarabe.journal.journalactivity import get_journal + get_journal().add_info_alert(None, + self._get_info_alert_title() + ' ...', + info_alert_message, False, + self._operate_per_metadata_per_action, + metadata) + else: + 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. + """ + + # Pass the callback for the post-operation-for-metadata. This + # will ensure that async-operations on the metadata are taken + # care of. + self._operate(metadata) + + def _operate(self, metadata): + """ + Actual, core, productive stage for EVERY metadata. + """ + + raise NotImplementedError + + def _post_operate_per_metadata_per_action(self, metadata): + """ + This is the stage, just after EVERY metadata has been + processed. + """ + + from jarabe.journal.journalactivity import get_journal + get_journal().remove_info_alert() + + # 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. + """ + + # Switch to non-editing mode (if applicable), after the operation is complete + if self._switch_to_normal_mode_after_completion: + from jarabe.journal.journalactivity import get_journal + get_journal().switch_to_editing_mode(False) + + def _inhibit_refresh(self, inhibit): + from jarabe.journal.journalactivity import get_journal + get_journal().get_list_view().inhibit_refresh(inhibit) + + def _refresh(self): + from jarabe.journal.journalactivity import get_journal + get_journal().get_list_view().refresh() + + 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) + # TRANS: Do not translate the two %d, and the three %s. + info_alert_message = _('( %d / %d ) Error while %s %s : %s') % ( + self._metadata_list_initial_len - current_len, + self._metadata_list_initial_len, + self._get_info_alert_title(), + metadata['title'], + error_message) -class VolumeMenu(MenuItem): - __gtype_name__ = 'JournalVolumeMenu' + from jarabe.journal.journalactivity import get_journal + get_journal().add_info_alert(None, + self._get_info_alert_title() + ' ...', + info_alert_message, True, + self._process_error_skipping, + metadata) + def _process_error_skipping(self, skip_all, metadata): + if skip_all: + self._skip_all = True + + # The operation for the current metadata is finished (kinda + # pseudo ...) + self._post_operate_per_metadata_per_action(metadata) + + +class BaseCopyMenuItem(MenuItem, ActionItem): __gsignals__ = { - 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, - ([str, str])), - } + 'volume-error': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, ([str, str])), + } - def __init__(self, metadata, label, mount_point): + + def __init__(self, metadata_list, label, show_editing_alert, + show_progress_info_alert, batch_mode): MenuItem.__init__(self, label) - self._metadata = metadata - self.connect('activate', self.__copy_to_volume_cb, mount_point) + ActionItem.__init__(self, label, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode, + need_to_popup_options=False, + operate_on_deselected_entries=False, + switch_to_normal_mode_after_completion=True) - def __copy_to_volume_cb(self, menu_item, mount_point): - file_path = model.get_file(self._metadata['uid']) + 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') + + +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) + self._mount_point = mount_point + + def _operate(self, metadata): + 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')) + error_message = _('Entries without a file cannot be copied.') + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self.emit('volume-error', error_message, _('Warning')) return try: - model.copy(self._metadata, mount_point) + 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')) - - -class ClipboardMenu(MenuItem): - __gtype_name__ = 'JournalClipboardMenu' + error_message = _('Error while copying the entry. %s') % e.strerror + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self.emit('volume-error', error_message, _('Error')) + return - __gsignals__ = { - 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, - ([str, str])), - } + # This is sync-operation. Thus, call the callback. + self._post_operate_per_metadata_per_action(metadata) - def __init__(self, metadata): - MenuItem.__init__(self, _('Clipboard')) - self._temp_file_path = None - self._metadata = metadata - self.connect('activate', self.__copy_to_clipboard_cb) +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) + self._temp_file_path_list = [] - def __copy_to_clipboard_cb(self, menu_item): - file_path = model.get_file(self._metadata['uid']) + def _operate(self, metadata): + 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')) + error_message = _('Entries without a file cannot be copied.') + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self.emit('volume-error', error_message, _('Warning')) return 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_operate_per_metadata_per_action(metadata) -class DocumentsMenu(MenuItem): - __gtype_name__ = 'JournalDocumentsMenu' - - __gsignals__ = { - 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, - ([str, str])), - } - - def __init__(self, metadata): - MenuItem.__init__(self, _('Documents')) - self._temp_file_path = None - self._metadata = metadata - self.connect('activate', self.__copy_to_documents_cb) +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) - def __copy_to_documents_cb(self, menu_item): - file_path = model.get_file(self._metadata['uid']) + def _operate(self, metadata): + 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')) + error_message = _('Entries without a file cannot be copied.') + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self.emit('volume-error', error_message, _('Warning')) return - model.copy(self._metadata, model.get_documents_path()) + model.copy(metadata, model.get_documents_path()) + + # This is sync-operation. Call the post-operation now. + self._post_operate_per_metadata_per_action(metadata) class GroupsMenu(gtk.Menu): @@ -538,3 +821,88 @@ 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.SIGNAL_RUN_FIRST, + gobject.TYPE_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.ICON_SIZE_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.ICON_SIZE_MENU)) + documents_menu.connect('volume-error', self.__volume_error_cb) + menu.append(documents_menu) + documents_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.ICON_SIZE_MENU)) + journal_menu.connect('volume-error', self.__volume_error_cb) + menu.append(journal_menu) + journal_menu.show() + + volume_monitor = gio.volume_monitor_get() + icon_theme = gtk.icon_theme_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.ICON_SIZE_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): + self.emit('volume-error', 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 -- 1.7.4.4 _______________________________________________ Sugar-devel mailing list [email protected] http://lists.sugarlabs.org/listinfo/sugar-devel

