[Sugar-devel] [sugar PATCH] sl#3317: Batch Operations on Journal Entries (Copy, Erase)

Ajay Garg ajay at activitycentral.com
Sun Feb 5 15:45:57 EST 2012


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.
   

 src/jarabe/journal/journalactivity.py |  135 ++++++++-
 src/jarabe/journal/journaltoolbox.py  |  176 ++++++++++-
 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, 844 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..7e1419a 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,172 @@ 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'
+
+        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.')
+            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.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