[Sugar-devel] [sugar PATCH v2] sl#3317: Batch Operations on Journal Entries (Copy, Erase)
Ajay Garg
ajay at activitycentral.com
Tue Feb 7 10:01:00 EST 2012
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 <walter at sugarlabs.org>
+# Copyright (C) 2012, Gonzalo Odiard <gonzalo at laptop.org>
+# Copyright (C) 2012, Martin Abente <tch at sugarlabs.org>
+# Copyright (C) 2012, Ajay Garg <ajay at activitycentral.com>
#
# 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 <walter at sugarlabs.org>
+# Copyright (C) 2012, Gonzalo Odiard <gonzalo at laptop.org>
+# Copyright (C) 2012, Martin Abente <tch at sugarlabs.org>
+# Copyright (C) 2012, Ajay Garg <ajay at activitycentral.com>
#
# 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 <walter at sugarlabs.org>
+# Copyright (C) 2012, Gonzalo Odiard <gonzalo at laptop.org>
+# Copyright (C) 2012, Martin Abente <tch at sugarlabs.org>
+# Copyright (C) 2012, Ajay Garg <ajay at activitycentral.com>
#
# 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 <walter at sugarlabs.org>
+# Copyright (C) 2012, Gonzalo Odiard <gonzalo at laptop.org>
+# Copyright (C) 2012, Martin Abente <tch at sugarlabs.org>
+# Copyright (C) 2012, Ajay Garg <ajay at activitycentral.com>
#
# 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 <walter at sugarlabs.org>
+# Copyright (C) 2012, Gonzalo Odiard <gonzalo at laptop.org>
+# Copyright (C) 2012, Martin Abente <tch at sugarlabs.org>
+# Copyright (C) 2012, Ajay Garg <ajay at activitycentral.com>
#
# 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 <walter at sugarlabs.org>
+# Copyright (C) 2012, Gonzalo Odiard <gonzalo at laptop.org>
+# Copyright (C) 2012, Martin Abente <tch at sugarlabs.org>
+# Copyright (C) 2012, Ajay Garg <ajay at activitycentral.com>
#
# 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
More information about the Sugar-devel
mailing list