diff options
Diffstat (limited to 'src/jarabe')
32 files changed, 4187 insertions, 275 deletions
diff --git a/src/jarabe/controlpanel/gui.py b/src/jarabe/controlpanel/gui.py index f8afca3..daa58e4 100644 --- a/src/jarabe/controlpanel/gui.py +++ b/src/jarabe/controlpanel/gui.py @@ -208,6 +208,10 @@ class ControlPanel(Gtk.Window): self.__accept_clicked_cb) def show_section_view(self, option): + self.get_window().set_cursor(Gdk.Cursor.new(Gdk.CursorType.WATCH)) + GObject.idle_add(self._finally_show_section_view, option) + + def _finally_show_section_view(self, option): self._set_toolbar(self._section_toolbar) icon = self._section_toolbar.get_icon() @@ -291,6 +295,7 @@ class ControlPanel(Gtk.Window): self._show_main_view() def __accept_clicked_cb(self, widget): + self._section_view.perform_accept_actions() if self._section_view.needs_restart: self._section_toolbar.accept_button.set_sensitive(False) self._section_toolbar.cancel_button.set_sensitive(False) diff --git a/src/jarabe/controlpanel/sectionview.py b/src/jarabe/controlpanel/sectionview.py index cbf4768..074a8ae 100644 --- a/src/jarabe/controlpanel/sectionview.py +++ b/src/jarabe/controlpanel/sectionview.py @@ -52,3 +52,7 @@ class SectionView(Gtk.VBox): def undo(self): """Undo here the changes that have been made in this section.""" pass + + def perform_accept_actions(self): + """Perform additional actions, when the "Ok" button is clicked.""" + pass diff --git a/src/jarabe/desktop/activitieslist.py b/src/jarabe/desktop/activitieslist.py index 4d2eb1a..4adaf18 100644 --- a/src/jarabe/desktop/activitieslist.py +++ b/src/jarabe/desktop/activitieslist.py @@ -215,6 +215,15 @@ class ListModel(Gtk.TreeModelSort): self._model.remove(row.iter) return + def _is_activity_bundle_in_model_already(self, activity_info): + bundle_id = activity_info.get_bundle_id() + version = activity_info.get_activity_version() + for row in self._model: + if row[ListModel.COLUMN_BUNDLE_ID] == bundle_id and \ + row[ListModel.COLUMN_VERSION] == version: + return True + return False + def _add_activity(self, activity_info): if activity_info.get_bundle_id() == 'org.laptop.JournalActivity': return @@ -223,6 +232,12 @@ class ListModel(Gtk.TreeModelSort): version = activity_info.get_activity_version() registry = bundleregistry.get_registry() + + # If the activity bundle is already a part of + # activities-list, do not re-add it. + if self._is_activity_bundle_in_model_already(activity_info): + return + favorite = registry.is_bundle_favorite(activity_info.get_bundle_id(), version) @@ -235,7 +250,7 @@ class ListModel(Gtk.TreeModelSort): '<span style="italic" weight="light">%s</span>' % \ (activity_info.get_name(), tags) - self._model.append([activity_info.get_bundle_id(), + self._model.prepend([activity_info.get_bundle_id(), favorite, activity_info.get_icon(), title, diff --git a/src/jarabe/desktop/keydialog.py b/src/jarabe/desktop/keydialog.py index a4c8e36..a6cde8d 100644 --- a/src/jarabe/desktop/keydialog.py +++ b/src/jarabe/desktop/keydialog.py @@ -20,10 +20,14 @@ from gettext import gettext as _ from gi.repository import Gtk import dbus +import os +import shutil +from sugar3 import env from sugar3.graphics.icon import Icon from jarabe.model import network +from jarabe.journal.objectchooser import ObjectChooser IW_AUTH_ALG_OPEN_SYSTEM = 'open' @@ -33,6 +37,10 @@ WEP_PASSPHRASE = 1 WEP_HEX = 2 WEP_ASCII = 3 +SETTING_TYPE_STRING = 1 +SETTING_TYPE_LIST = 2 +SETTING_TYPE_CHOOSER = 3 + def string_is_hex(key): is_hex = True @@ -74,6 +82,216 @@ class CanceledKeyRequestError(dbus.DBusException): self._dbus_error_name = network.NM_SETTINGS_IFACE + '.CanceledError' +class NetworkParameters(Gtk.HBox): + def __init__(self, auth_param): + Gtk.HBox.__init__(self, homogeneous=True) + self._key = auth_param._key_name + self._label = Gtk.Label(_(auth_param._key_label)) + self._key_type = auth_param._key_type + self._auth_param = auth_param + + self.pack_start(self._label, True, True, 0) + self._label.show() + + if self._is_entry(): + self._entry = Gtk.Entry() + self.pack_start(self._entry, True, True, 0) + self._entry.show() + elif self._is_liststore(): + self._option_store = Gtk.ListStore(str, str) + for option in auth_param._options: + self._option_store.append(option) + + self._entry = auth_param._options[0][1] + self._option_combo = Gtk.ComboBox(model=self._option_store) + cell = Gtk.CellRendererText() + self._option_combo.pack_start(cell, True) + self._option_combo.add_attribute(cell, 'text', 0) + self._option_combo.set_active(0) + self._option_combo.connect('changed', + self._option_combo_changed_cb) + self.pack_start(self._option_combo, True, True, 0) + self.show() + self._option_combo.show() + elif self._is_chooser(): + self._chooser_button = Gtk.Button(_('Choose..')) + self._chooser_button.connect('clicked', + self._object_chooser_cb) + self.pack_start(self._chooser_button, True, True, 0) + self._chooser_button.show() + self._entry = '' + + def _is_entry(self): + return ( not self._is_chooser() ) and \ + ( len(self._auth_param._options) == 0 ) + + def _is_liststore(self): + return ( not self._is_chooser() ) and \ + ( len(self._auth_param._options) > 0 ) + + def _is_chooser(self): + return self._key_type == SETTING_TYPE_CHOOSER + + def _object_chooser_cb(self, chooser_button): + self._want_document = True + self._show_picker_cb() + + def _show_picker_cb(self): + if not self._want_document: + return + self._chooser = ObjectChooser() + self._chooser._set_callback(self.__process_selected_journal_object) + + self._chooser.show() + + def __process_selected_journal_object(self, object_id): + jobject = self._chooser.get_selected_object() + if jobject and jobject.file_path: + file_basename = \ + os.path.basename(jobject._metadata._properties['title']) + self._chooser_button.set_label(file_basename) + + profile_path = env.get_profile_path() + self._entry = os.path.join(profile_path, file_basename) + + # Remove (older) file, if it exists. + if os.path.exists(self._entry): + os.remove(self._entry) + + # Copy the file. + shutil.copy2(jobject.file_path, self._entry) + + self._chooser.destroy() + + def _option_combo_changed_cb(self, widget): + it = self._option_combo.get_active_iter() + (value, ) = self._option_store.get(it, 1) + self._entry = value + + def _get_key(self): + return self._key + + def _get_value(self): + if self._is_entry(): + return self._entry.get_text() + elif self._is_liststore(): + return self._entry + elif self._is_chooser(): + if len(self._entry) > 0: + return dbus.ByteArray('file://' + self._entry + '\0') + else: + return self._entry + + +class KeyValuesDialog(Gtk.Dialog): + def __init__(self, auth_lists, final_callback, settings): + # This must not be "modal", else the "chooser" widgets won't + # accept anything !! + Gtk.Dialog.__init__(self) + self.set_title(_('Wireless Parameters required')) + + self._spacing_between_children_widgets = 5 + self._auth_lists = auth_lists + self._final_callback = final_callback + self._settings = settings + + label = Gtk.Label(_("Please enter parameters\n")) + self.vbox.set_spacing(self._spacing_between_children_widgets) + self.vbox.pack_start(label, True, True, 0) + + self._auth_type_store = Gtk.ListStore(str, str) + for auth_list in self._auth_lists: + self._auth_type_store.append([auth_list._auth_label, + auth_list._auth_type]) + + self._auth_type_combo = Gtk.ComboBox(model=self._auth_type_store) + cell = Gtk.CellRendererText() + self._auth_type_combo.pack_start(cell, True) + self._auth_type_combo.add_attribute(cell, 'text', 0) + self._auth_type_combo.set_active(0) + self._auth_type_combo.connect('changed', + self._auth_type_combo_changed_cb) + self._auth_type_box = Gtk.HBox(homogeneous=True) + self._auth_label = Gtk.Label(_('Authentication')) + self._auth_type_box.pack_start(self._auth_label, True, True, 0) + self._auth_type_box.pack_start(self._auth_type_combo, + True, True, 0) + self.vbox.pack_start(self._auth_type_box, True, True, 0) + self._auth_label.show() + self._auth_type_combo.show() + + button = Gtk.Button() + button.set_image(Icon(icon_name='dialog-cancel')) + button.set_label(_('Cancel')) + self.add_action_widget(button, Gtk.ResponseType.CANCEL) + button = Gtk.Button() + button.set_image(Icon(icon_name='dialog-ok')) + button.set_label(_('Ok')) + self.add_action_widget(button, Gtk.ResponseType.OK) + self.set_default_response(Gtk.ResponseType.OK) + + self.connect('response', self._fetch_values) + + auth_type = self._auth_lists[0]._auth_type + self._selected_auth_list = self._select_auth_list(auth_type) + self._add_key_value('eap', auth_type) + self._add_container_box() + + def _auth_type_combo_changed_cb(self, widget): + it = self._auth_type_combo.get_active_iter() + (auth_type, ) = self._auth_type_store.get(it, 1) + self._selected_auth_list = self._select_auth_list(auth_type) + self._add_key_value('eap', auth_type) + self._reset() + + def _select_auth_list(self, auth_type): + for auth_list in self._auth_lists: + if auth_list._params_list[0]._options[0][1] == auth_type: + return auth_list + + def _populate_auth_params(self, auth_list): + for auth_param in auth_list._params_list[1:]: + obj = NetworkParameters(auth_param) + self._key_values_box.pack_start(obj, True, True, 0) + obj.show() + + def _reset(self): + self.vbox.remove(self._key_values_box) + self._add_container_box() + + def _add_container_box(self): + self._key_values_box = \ + Gtk.VBox(spacing=self._spacing_between_children_widgets) + self.vbox.pack_start(self._key_values_box, True, True, 0) + self._key_values_box.show() + self._populate_auth_params(self._selected_auth_list) + + def _remove_all_params(self): + self._key_values_box.remove_all() + + def _fetch_values(self, key_dialog, response_id): + if response_id == Gtk.ResponseType.OK: + for child in self._key_values_box.get_children(): + key = child._get_key() + value = child._get_value() + self._add_key_value(key, value) + + key_dialog.destroy() + self._final_callback(self._settings, + self._selected_auth_list) + + def _add_key_value(self, key, value): + for auth_param in self._selected_auth_list._params_list: + if auth_param._key_name == key: + if (auth_param._key_type == SETTING_TYPE_STRING) or \ + (auth_param._key_type == SETTING_TYPE_CHOOSER): + auth_param._value = value + elif auth_param._key_type == SETTING_TYPE_LIST: + values = [] + values.append(value) + auth_param._value = values + + class KeyDialog(Gtk.Dialog): def __init__(self, ssid, flags, wpa_flags, rsn_flags, dev_caps, response): Gtk.Dialog.__init__(self, flags=Gtk.DialogFlags.MODAL) @@ -219,7 +437,7 @@ class WEPKeyDialog(KeyDialog): self.set_response_sensitive(Gtk.ResponseType.OK, valid) -class WPAKeyDialog(KeyDialog): +class WPAPersonalKeyDialog(KeyDialog): def __init__(self, ssid, flags, wpa_flags, rsn_flags, dev_caps, response): KeyDialog.__init__(self, ssid, flags, wpa_flags, rsn_flags, dev_caps, response) @@ -295,14 +513,26 @@ def create(ssid, flags, wpa_flags, rsn_flags, dev_caps, response): rsn_flags == network.NM_802_11_AP_SEC_NONE: key_dialog = WEPKeyDialog(ssid, flags, wpa_flags, rsn_flags, dev_caps, response) - else: - key_dialog = WPAKeyDialog(ssid, flags, wpa_flags, rsn_flags, - dev_caps, response) + elif (wpa_flags & network.NM_802_11_AP_SEC_KEY_MGMT_PSK) or \ + (rsn_flags & network.NM_802_11_AP_SEC_KEY_MGMT_PSK): + key_dialog = WPAPersonalKeyDialog(ssid, flags, wpa_flags, rsn_flags, + dev_caps, settings, response) + elif (wpa_flags & network.NM_802_11_AP_SEC_KEY_MGMT_802_1X) or \ + (rsn_flags & network.NM_802_11_AP_SEC_KEY_MGMT_802_1X): + # nothing. All details are asked for WPA/WPA2-Enterprise + # networks, before the conneection-activation is done. + return key_dialog.connect('response', _key_dialog_response_cb) key_dialog.show_all() +def get_key_values(key_list, final_callback, settings): + key_dialog = KeyValuesDialog(key_list, final_callback, + settings) + key_dialog.show_all() + + def _key_dialog_response_cb(key_dialog, response_id): response = key_dialog.get_response_object() secrets = None diff --git a/src/jarabe/desktop/networkviews.py b/src/jarabe/desktop/networkviews.py index 64b4be3..eccfd17 100644 --- a/src/jarabe/desktop/networkviews.py +++ b/src/jarabe/desktop/networkviews.py @@ -23,6 +23,7 @@ import uuid import dbus import glib +import string from sugar3.graphics.icon import Icon from sugar3.graphics.xocolor import XoColor @@ -48,6 +49,192 @@ _OLPC_MESH_ICON_NAME = 'network-mesh' _FILTERED_ALPHA = 0.33 +SETTING_TYPE_STRING = 1 +SETTING_TYPE_LIST = 2 +SETTING_TYPE_CHOOSER = 3 + + +class AuthenticationType: + def __init__(self, auth_label, auth_type, params_list): + self._auth_label = auth_label + self._auth_type = auth_type + self._params_list = params_list + + +class AuthenticationParameter: + def __init__(self, key_name, key_label, key_type, + options): + self._key_name = key_name + self._key_label = key_label + self._key_type = key_type + self._options = options + self._value = None + + + +AUTHENTICATION_LIST = \ + [ + AuthenticationType('TLS', + 'tls', + [ + AuthenticationParameter( + 'eap', + 'Authentication', + SETTING_TYPE_LIST, + [['TLS', 'tls']] + ), + AuthenticationParameter( + 'identity', + 'Identity', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'client-cert', + 'User certificate', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'ca-cert', + 'CA certificate', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'private-key', + 'Private key', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'private-key-password', + 'Private Key password', + SETTING_TYPE_STRING, + [] + ) + ] + ), + AuthenticationType('LEAP', + 'leap', + [ + AuthenticationParameter( + 'eap', + 'Authentication', + SETTING_TYPE_LIST, + [['LEAP', 'leap']] + ), + AuthenticationParameter( + 'identity', + 'Username', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'password', + 'Password', + SETTING_TYPE_STRING, + [] + ) + ] + ), + AuthenticationType('Tunnelled TLS', + 'ttls', + [ + AuthenticationParameter( + 'eap', + 'Authentication', + SETTING_TYPE_LIST, + [['Tunnelled TLS', 'ttls']] + ), + AuthenticationParameter( + 'anonymous-identity', + 'Anonymous identity', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'ca-cert', + 'CA certificate', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'phase2-auth', + 'Inner Authentication', + SETTING_TYPE_STRING, + [['PAP', 'pap'], + ['MSCHAP', 'mschap'], + ['MSCHAPv2', 'mschapv2'], + ['CHAP', 'chap']] + ), + AuthenticationParameter( + 'identity', + 'Username', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'password', + 'Password', + SETTING_TYPE_STRING, + [] + ) + ] + ), + AuthenticationType('Protected EAP (PEAP)', + 'peap', + [ + AuthenticationParameter( + 'eap', + 'Authentication', + SETTING_TYPE_LIST, + [['Protected EAP (PEAP)', 'peap']] + ), + AuthenticationParameter( + 'anonymous-identity', + 'Anonymous identity', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'ca-cert', + 'CA certificate', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'phase1-peapver', + 'PEAP version', + SETTING_TYPE_STRING, + [['Automatic', ''], + ['Version 0', '0'], + ['Version 1', '1']] + ), + AuthenticationParameter( + 'phase2-auth', + 'Inner Authentication', + SETTING_TYPE_STRING, + [['MSCHAPv2', 'mschapv2'], + ['MD5', 'md5'], + ['GTC', 'gtc']] + ), + AuthenticationParameter( + 'identity', + 'Username', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'password', + 'Password', + SETTING_TYPE_STRING, + [] + ) + ] + ) + ] + class WirelessNetworkView(EventPulsingIcon): def __init__(self, initial_ap): @@ -312,7 +499,7 @@ class WirelessNetworkView(EventPulsingIcon): group = self._add_ciphers_from_flags(self._rsn_flags, False) wireless_security = WirelessSecurity() wireless_security.key_mgmt = 'wpa-psk' - wireless_security.proto = 'rsn' + wireless_security.proto = ['rsn'] wireless_security.pairwise = pairwise wireless_security.group = group return wireless_security @@ -324,7 +511,31 @@ class WirelessNetworkView(EventPulsingIcon): group = self._add_ciphers_from_flags(self._wpa_flags, False) wireless_security = WirelessSecurity() wireless_security.key_mgmt = 'wpa-psk' - wireless_security.proto = 'wpa' + wireless_security.proto = ['wpa'] + wireless_security.pairwise = pairwise + wireless_security.group = group + return wireless_security + + if (self._rsn_flags & network.NM_802_11_AP_SEC_KEY_MGMT_802_1X) and \ + (self._device_caps & network.NM_WIFI_DEVICE_CAP_RSN): + # WPA2 Enterprise + pairwise = self._add_ciphers_from_flags(self._rsn_flags, True) + group = self._add_ciphers_from_flags(self._rsn_flags, False) + wireless_security = WirelessSecurity() + wireless_security.key_mgmt = 'wpa-eap' + wireless_security.proto = ['rsn'] + wireless_security.pairwise = pairwise + wireless_security.group = group + return wireless_security + + if (self._wpa_flags & network.NM_802_11_AP_SEC_KEY_MGMT_802_1X) and \ + (self._device_caps & network.NM_WIFI_DEVICE_CAP_WPA): + # WPA Enterprise + pairwise = self._add_ciphers_from_flags(self._wpa_flags, True) + group = self._add_ciphers_from_flags(self._wpa_flags, False) + wireless_security = WirelessSecurity() + wireless_security.key_mgmt = 'wpa-eap' + wireless_security.proto = ['wpa'] wireless_security.pairwise = pairwise wireless_security.group = group return wireless_security @@ -359,8 +570,9 @@ class WirelessNetworkView(EventPulsingIcon): elif self._mode == network.NM_802_11_MODE_ADHOC: settings.wireless.mode = 'adhoc' settings.wireless.band = 'bg' - settings.ip4_config = IP4Config() - settings.ip4_config.method = 'link-local' + if network.is_sugar_adhoc_network(self._name): + settings.ip4_config = IP4Config() + settings.ip4_config.method = 'link-local' wireless_security = self._get_security() settings.wireless_security = wireless_security @@ -368,6 +580,34 @@ class WirelessNetworkView(EventPulsingIcon): if wireless_security is not None: settings.wireless.security = '802-11-wireless-security' + # Take in the settings, if applicable. + if (wireless_security is not None) and \ + ( (wireless_security.key_mgmt == 'ieee8021x') or \ + (wireless_security.key_mgmt == 'wpa-eap') ): + keydialog.get_key_values(AUTHENTICATION_LIST, + self.__add_and_activate_connection, + settings) + else: + self.__add_and_activate_connection(settings) + + def __add_and_activate_connection(self, settings, additional_settings=None): + if additional_settings is not None: + key_value_dict = {} + auth_params_list = additional_settings._params_list + + for auth_param in auth_params_list: + key = auth_param._key_name + value = auth_param._value + logging.debug('key == %s', key) + logging.debug('value == %s', value) + if len(value) > 0: + key_value_dict[key] = value + else: + logging.debug('Not setting empty value for key :' + ' %s', key) + + settings.wpa_eap_setting = key_value_dict + network.add_and_activate_connection(self._device, settings, self.get_first_ap().model) diff --git a/src/jarabe/frame/__init__.py b/src/jarabe/frame/__init__.py index b3e4b80..8732b96 100644 --- a/src/jarabe/frame/__init__.py +++ b/src/jarabe/frame/__init__.py @@ -14,13 +14,13 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -from jarabe.frame.frame import Frame - _view = None def get_view(): + from jarabe.frame.frame import Frame + global _view if not _view: _view = Frame() diff --git a/src/jarabe/frame/activitiestray.py b/src/jarabe/frame/activitiestray.py index 38fde7b..ef8435f 100644 --- a/src/jarabe/frame/activitiestray.py +++ b/src/jarabe/frame/activitiestray.py @@ -246,6 +246,24 @@ class ActivitiesTray(HTray): button.connect('clicked', self.__activity_clicked_cb, home_activity) button.show() + # JournalActivity is always the first activity to be added. + # Broadcast the signal-of-its-creation. + if group is None: + self._signal_addition_of_journal_activity() + + def _signal_addition_of_journal_activity(self): + monitor_file = os.path.expanduser('~/.sugar/journal_created') + + # Remove the file, if it exists. + # This is important, since we are checking for the + # FILE_CREATED event in the monitor. + if os.path.exists(monitor_file): + os.remove(monitor_file) + + # Now, create the file. + f = open(monitor_file, 'w') + f.close() + def __activity_removed_cb(self, home_model, home_activity): logging.debug('__activity_removed_cb: %r', home_activity) button = self._buttons[home_activity] diff --git a/src/jarabe/frame/frame.py b/src/jarabe/frame/frame.py index 410e08b..659df19 100644 --- a/src/jarabe/frame/frame.py +++ b/src/jarabe/frame/frame.py @@ -15,6 +15,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA import logging +import os from gi.repository import Gtk from gi.repository import Gdk @@ -33,6 +34,7 @@ from jarabe.frame.devicestray import DevicesTray from jarabe.frame.framewindow import FrameWindow from jarabe.frame.clipboardpanelwindow import ClipboardPanelWindow from jarabe.frame.notification import NotificationIcon, NotificationWindow +from jarabe.frame.notification import NotificationButton, HistoryPalette from jarabe.model import notifications @@ -43,6 +45,8 @@ BOTTOM_LEFT = 3 _NOTIFICATION_DURATION = 5000 +_DEFAULT_ICON = 'emblem-notification' + class _Animation(animator.Animation): def __init__(self, frame, end): @@ -83,6 +87,10 @@ class Frame(object): self._event_area.connect('enter', self._enter_corner_cb) self._event_area.show() + self._activities_tray = None + self._devices_tray = None + self._friends_tray = None + self._top_panel = self._create_top_panel() self._bottom_panel = self._create_bottom_panel() self._left_panel = self._create_left_panel() @@ -94,6 +102,7 @@ class Frame(object): self._key_listener = _KeyListener(self) self._notif_by_icon = {} + self._notif_by_message = {} notification_service = notifications.get_service() notification_service.notification_received.connect( @@ -143,6 +152,8 @@ class Frame(object): panel.append(activities_tray) activities_tray.show() + self._activities_tray = activities_tray + return panel def _create_bottom_panel(self): @@ -152,6 +163,8 @@ class Frame(object): panel.append(devices_tray) devices_tray.show() + self._devices_tray = devices_tray + return panel def _create_right_panel(self): @@ -161,6 +174,8 @@ class Frame(object): panel.append(tray) tray.show() + self._friends_tray = tray + return panel def _create_left_panel(self): @@ -211,15 +226,7 @@ class Frame(object): else: self.show() - def notify_key_press(self): - self._key_listener.key_press() - - def add_notification(self, icon, corner=Gtk.CornerType.TOP_LEFT, - duration=_NOTIFICATION_DURATION): - - if not isinstance(icon, NotificationIcon): - raise TypeError('icon must be a NotificationIcon.') - + def _create_notification_window(self, corner): window = NotificationWindow() screen = Gdk.Screen.get_default() @@ -235,6 +242,47 @@ class Frame(object): else: raise ValueError('Inalid corner: %r' % corner) + return window + + def _add_message_button(self, button, corner): + if corner == Gtk.CornerType.BOTTOM_RIGHT: + self._devices_tray.add_item(button) + elif corner == Gtk.CornerType.TOP_RIGHT: + self._friends_tray.add_item(button) + else: + self._activities_tray.add_item(button) + + def _remove_message_button(self, button, corner): + if corner == Gtk.CornerType.BOTTOM_RIGHT: + self._devices_tray.remove_item(button) + elif corner == Gtk.CornerType.TOP_RIGHT: + self._friends_tray.remove_item(button) + else: + self._activities_tray.remove_item(button) + + def _launch_notification_icon(self, icon_name, xo_color, + position, duration): + icon = NotificationIcon() + icon.props.xo_color = xo_color + + if icon_name.startswith(os.sep): + icon.props.icon_filename = icon_name + else: + icon.props.icon_name = icon_name + + self.add_notification(icon, position, duration) + + def notify_key_press(self): + self._key_listener.key_press() + + def add_notification(self, icon, corner=Gtk.CornerType.TOP_LEFT, + duration=_NOTIFICATION_DURATION): + + if not isinstance(icon, NotificationIcon): + raise TypeError('icon must be a NotificationIcon.') + + window = self._create_notification_window(corner) + window.add(icon) icon.show() window.show() @@ -253,28 +301,77 @@ class Frame(object): window.destroy() del self._notif_by_icon[icon] + def add_message(self, body, summary='', icon_name=_DEFAULT_ICON, + xo_color=None, corner=Gtk.CornerType.TOP_LEFT, + duration=_NOTIFICATION_DURATION): + + if xo_color is None: + xo_color = profile.get_color() + + button = self._notif_by_message.get(corner, None) + if button is None: + button = NotificationButton(_DEFAULT_ICON, xo_color) + button.show() + self._add_message_button(button, corner) + self._notif_by_message[corner] = button + + palette = button.get_palette() + if palette is None: + palette = HistoryPalette() + palette.set_group_id('frame') + palette.connect('clear-messages', self.remove_message, corner) + palette.connect('notice-messages', button.stop_pulsing) + button.set_palette(palette) + + button.start_pulsing() + + palette.push_message(body, summary, icon_name, xo_color) + if not self.visible: + self._launch_notification_icon(_DEFAULT_ICON, xo_color, corner, duration) + + def remove_message(self, palette, corner): + if corner not in self._notif_by_message: + logging.debug('Button %s is not active', str(corner)) + return + + button = self._notif_by_message[corner] + self._remove_message_button(button, corner) + del self._notif_by_message[corner] + def __notification_received_cb(self, **kwargs): - logging.debug('__notification_received_cb') - icon = NotificationIcon() + logging.debug('__notification_received_cb %r', kwargs) hints = kwargs['hints'] - icon_file_name = hints.get('x-sugar-icon-file-name', '') - if icon_file_name: - icon.props.icon_filename = icon_file_name - else: - icon.props.icon_name = 'application-octet-stream' + icon_name = hints.get('x-sugar-icon-file-name', '') + if not icon_name: + icon_name = _DEFAULT_ICON icon_colors = hints.get('x-sugar-icon-colors', '') if not icon_colors: icon_colors = profile.get_color() - icon.props.xo_color = icon_colors duration = kwargs.get('expire_timeout', -1) if duration == -1: duration = _NOTIFICATION_DURATION - self.add_notification(icon, Gtk.CornerType.TOP_RIGHT, duration) + category = hints.get('category', '') + if category == 'device': + position = Gtk.CornerType.BOTTOM_RIGHT + elif category == 'presence': + position = Gtk.CornerType.TOP_RIGHT + else: + position = Gtk.CornerType.TOP_LEFT + + summary = kwargs.get('summary', '') + body = kwargs.get('body', '') + + if summary or body: + self.add_message(body, summary, icon_name, + icon_colors, position, duration) + else: + self._launch_notification_icon(icon_name, icon_colors, + position, duration) def __notification_cancelled_cb(self, **kwargs): # Do nothing for now. Our notification UI is so simple, there's no diff --git a/src/jarabe/frame/notification.py b/src/jarabe/frame/notification.py index 184a779..3adaed1 100644 --- a/src/jarabe/frame/notification.py +++ b/src/jarabe/frame/notification.py @@ -18,11 +18,197 @@ from gi.repository import GObject from gi.repository import Gtk from gi.repository import Gdk +import re +import os + +from gettext import gettext as _ + from sugar3.graphics import style from sugar3.graphics.xocolor import XoColor +from sugar3.graphics.palette import Palette +from sugar3.graphics.palettemenuitem import PaletteMenuItem +from sugar3.graphics.toolbutton import ToolButton +from sugar3 import profile + +from jarabe.frame.frameinvoker import FrameWidgetInvoker from jarabe.view.pulsingicon import PulsingIcon +_PULSE_TIMEOUT = 3 +_PULSE_COLOR = XoColor('%s,%s' % \ + (style.COLOR_BUTTON_GREY.get_svg(), style.COLOR_TRANSPARENT.get_svg())) +_BODY_FILTERS = "<img.*?/>" + + +def _create_pulsing_icon(icon_name, xo_color, timeout=None): + icon = PulsingIcon( + pixel_size=style.STANDARD_ICON_SIZE, + pulse_color=_PULSE_COLOR, + base_color=xo_color + ) + + if timeout is not None: + icon.timeout = timeout + + if icon_name.startswith(os.sep): + icon.props.file = icon_name + else: + icon.props.icon_name = icon_name + + return icon + + +class _HistoryIconWidget(Gtk.Alignment): + __gtype_name__ = 'SugarHistoryIconWidget' + + def __init__(self, icon_name, xo_color): + icon = _create_pulsing_icon(icon_name, xo_color, _PULSE_TIMEOUT) + icon.props.pulsing = True + + Gtk.Alignment.__init__(self, xalign=0.5, yalign=0.0) + self.props.top_padding = style.DEFAULT_PADDING + self.set_size_request( + style.GRID_CELL_SIZE - style.FOCUS_LINE_WIDTH * 2, + style.GRID_CELL_SIZE - style.DEFAULT_PADDING) + self.add(icon) + + +class _HistorySummaryWidget(Gtk.Alignment): + __gtype_name__ = 'SugarHistorySummaryWidget' + + def __init__(self, summary): + summary_label = Gtk.Label() + summary_label.props.wrap = True + summary_label.set_markup( + '<b>%s</b>' % GObject.markup_escape_text(summary)) + + Gtk.Alignment.__init__(self, xalign=0.0, yalign=1.0) + self.props.right_padding = style.DEFAULT_SPACING + self.add(summary_label) + + +class _HistoryBodyWidget(Gtk.Alignment): + __gtype_name__ = 'SugarHistoryBodyWidget' + def __init__(self, body): + body_label = Gtk.Label() + body_label.props.wrap = True + body_label.set_markup(body) + + Gtk.Alignment.__init__(self, xalign=0, yalign=0.0) + self.props.right_padding = style.DEFAULT_SPACING + self.add(body_label) + + +class _MessagesHistoryBox(Gtk.VBox): + __gtype_name__ = 'SugarMessagesHistoryBox' + + def __init__(self): + Gtk.VBox.__init__(self) + self._setup_links_style() + + def _setup_links_style(self): + # XXX: find a better way to change style for upstream + link_color = profile.get_color().get_fill_color() + visited_link_color = profile.get_color().get_stroke_color() + + links_style=''' + style "label" { + GtkLabel::link-color="%s" + GtkLabel::visited-link-color="%s" + } + widget_class "*GtkLabel" style "label" + ''' % (link_color, visited_link_color) + Gtk.rc_parse_string(links_style) + + def push_message(self, body, summary, icon_name, xo_color): + entry = Gtk.HBox() + + icon_widget = _HistoryIconWidget(icon_name, xo_color) + entry.pack_start(icon_widget, False, False, 0) + + message = Gtk.VBox() + message.props.border_width = style.DEFAULT_PADDING + entry.pack_start(message, True, True, 0) + + if summary: + summary_widget = _HistorySummaryWidget(summary) + message.pack_start(summary_widget, False, False, 0) + + body = re.sub(_BODY_FILTERS, '', body) + + if body: + body_widget = _HistoryBodyWidget(body) + message.pack_start(body_widget, True, True, 0) + + entry.show_all() + self.pack_start(entry, True, True, 0) + self.reorder_child(entry, 0) + + self_width_ = self.props.width_request + self_height = self.props.height_request + if (self_height > Gdk.Screen.height() / 4 * 3) and \ + (len(self.get_children()) > 1): + self.remove(self.get_children()[-1]) + +class HistoryPalette(Palette): + __gtype_name__ = 'SugarHistoryPalette' + + __gsignals__ = { + 'clear-messages': (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'notice-messages': (GObject.SignalFlags.RUN_FIRST, None, ([])) + } + + def __init__(self): + Palette.__init__(self) + + self._update_accept_focus() + + self._messages_box = _MessagesHistoryBox() + self._messages_box.show() + + palette_box = self._palette_box + primary_box = self._primary_box + primary_box.hide() + palette_box.add(self._messages_box) + palette_box.reorder_child(self._messages_box, 0) + + clear_option = PaletteMenuItem(_('Clear history'), 'dialog-cancel') + clear_option.connect('activate', self.__clear_messages_cb) + clear_option.show() + + vbox = Gtk.VBox() + self.set_content(vbox) + vbox.show() + + vbox.add(clear_option) + + self.connect('popup', self.__notice_messages_cb) + + def __clear_messages_cb(self, clear_option): + self.emit('clear-messages') + + def __notice_messages_cb(self, palette): + self.emit('notice-messages') + + def push_message(self, body, summary, icon_name, xo_color): + self._messages_box.push_message(body, summary, icon_name, xo_color) + + +class NotificationButton(ToolButton): + + def __init__(self, icon_name, xo_color): + ToolButton.__init__(self) + self._icon = _create_pulsing_icon(icon_name, xo_color) + self.set_icon_widget(self._icon) + self._icon.show() + self.set_palette_invoker(FrameWidgetInvoker(self)) + + def start_pulsing(self): + self._icon.props.pulsing = True + + def stop_pulsing(self, widget): + self._icon.props.pulsing = False + class NotificationIcon(Gtk.EventBox): __gtype_name__ = 'SugarNotificationIcon' @@ -33,28 +219,29 @@ class NotificationIcon(Gtk.EventBox): 'icon-filename': (str, None, None, None, GObject.PARAM_READWRITE), } - _PULSE_TIMEOUT = 3 - def __init__(self, **kwargs): self._icon = PulsingIcon(pixel_size=style.STANDARD_ICON_SIZE) Gtk.EventBox.__init__(self, **kwargs) self.props.visible_window = False + self.set_app_paintable(True) - self._icon.props.pulse_color = \ - XoColor('%s,%s' % (style.COLOR_BUTTON_GREY.get_svg(), - style.COLOR_TRANSPARENT.get_svg())) - self._icon.props.pulsing = True + color = Gdk.color_parse(style.COLOR_BLACK.get_html()) + self.modify_bg(Gtk.StateType.PRELIGHT, color) + + color = Gdk.color_parse(style.COLOR_BUTTON_GREY.get_html()) + self.modify_bg(Gtk.StateType.ACTIVE, color) + + self._icon.props.pulse_color = _PULSE_COLOR + self._icon.props.timeout = _PULSE_TIMEOUT self.add(self._icon) self._icon.show() - GObject.timeout_add_seconds(self._PULSE_TIMEOUT, - self.__stop_pulsing_cb) + self.start_pulsing() self.set_size_request(style.GRID_CELL_SIZE, style.GRID_CELL_SIZE) - def __stop_pulsing_cb(self): - self._icon.props.pulsing = False - return False + def start_pulsing(self): + self._icon.props.pulsing = True def do_set_property(self, pspec, value): if pspec.name == 'xo-color': @@ -87,17 +274,13 @@ class NotificationIcon(Gtk.EventBox): class NotificationWindow(Gtk.Window): __gtype_name__ = 'SugarNotificationWindow' - def __init__(self, **kwargs): - - Gtk.Window.__init__(self, **kwargs) + def __init__(self): + Gtk.Window.__init__(self) self.set_decorated(False) self.set_resizable(False) self.connect('realize', self._realize_cb) def _realize_cb(self, widget): - self.set_type_hint(Gdk.WindowTypeHint.DIALOG) - self.window.set_accept_focus(False) - color = Gdk.color_parse(style.COLOR_TOOLBAR_GREY.get_html()) self.modify_bg(Gtk.StateType.NORMAL, color) diff --git a/src/jarabe/intro/window.py b/src/jarabe/intro/window.py index 252870d..469514e 100644 --- a/src/jarabe/intro/window.py +++ b/src/jarabe/intro/window.py @@ -34,12 +34,30 @@ from sugar3.graphics.xocolor import XoColor from jarabe.intro import colorpicker -def create_profile(name, color=None): +def create_profile(name, age, color=None): if not color: color = XoColor() client = GConf.Client.get_default() client.set_string('/desktop/sugar/user/nick', name) + + + # Algorithm to generate the timestamp of the birthday of the + # XO-user :: + # + # timestamp = current_timestamp - [age * (365 * 24 * 60 * 60)] + # + # Note that, this timestamp may actually (in worst-case) be + # off-target by 1 year, but that is ok, since we want an + # "approximate" age of the XO-user (for statistics-collection). + import time + current_timestamp = time.time() + xo_user_age_as_timestamp = int(age) * 365 * 24 * 60 * 60 + + approx_timestamp_at_user_birthday = current_timestamp - xo_user_age_as_timestamp + client.set_int('/desktop/sugar/user/birth_timestamp', int(approx_timestamp_at_user_birthday)) + # Done. + client.set_string('/desktop/sugar/user/color', color.to_string()) client.suggest_sync() @@ -125,6 +143,46 @@ class _NamePage(_Page): self._entry.grab_focus() +class _AgePage(_Page): + def __init__(self, intro): + _Page.__init__(self) + self._intro = intro + self._max_age = 1000 + + alignment = Gtk.Alignment.new(0.5, 0.5, 0, 0) + self.pack_start(alignment, expand=True, fill=True, padding=0) + + hbox = Gtk.HBox(spacing=style.DEFAULT_SPACING) + alignment.add(hbox) + + label = Gtk.Label(_('Age:')) + hbox.pack_start(label, False, True, 0) + + adjustment = Gtk.Adjustment(0, 0, self._max_age, 1, 0, 0) + self._entry = Gtk.SpinButton(adjustment=adjustment) + self._entry.props.editable = True + self._entry.connect('notify::text', self._text_changed_cb) + self._entry.set_max_length(15) + hbox.pack_start(self._entry, False, True, 0) + + label = Gtk.Label(_('years')) + hbox.pack_start(label, False, True, 0) + + + def _text_changed_cb(self, entry, pspec): + valid = False + if entry.props.text.isdigit(): + int_value = int(entry.props.text) + valid = ((int_value > 0) and (int_value <= self._max_age)) + self.set_valid(valid) + + def get_age(self): + return int(self._entry.props.text) + + def activate(self): + self._entry.grab_focus() + + class _ColorPage(_Page): def __init__(self): _Page.__init__(self) @@ -148,11 +206,12 @@ class _ColorPage(_Page): class _IntroBox(Gtk.VBox): __gsignals__ = { 'done': (GObject.SignalFlags.RUN_FIRST, None, - ([GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT])), + ([GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT])), } PAGE_NAME = 0 - PAGE_COLOR = 1 + PAGE_AGE = 1 + PAGE_COLOR = 2 PAGE_FIRST = PAGE_NAME PAGE_LAST = PAGE_COLOR @@ -163,6 +222,7 @@ class _IntroBox(Gtk.VBox): self._page = self.PAGE_NAME self._name_page = _NamePage(self) + self._age_page = _AgePage(self) self._color_page = _ColorPage() self._current_page = None self._next_button = None @@ -185,6 +245,8 @@ class _IntroBox(Gtk.VBox): if self._page == self.PAGE_NAME: self._current_page = self._name_page + if self._page == self.PAGE_AGE: + self._current_page = self._age_page elif self._page == self.PAGE_COLOR: self._current_page = self._color_page @@ -253,9 +315,10 @@ class _IntroBox(Gtk.VBox): def done(self): name = self._name_page.get_name() + age = self._age_page.get_age() color = self._color_page.get_color() - self.emit('done', name, color) + self.emit('done', name, age, color) class IntroWindow(Gtk.Window): @@ -274,12 +337,12 @@ class IntroWindow(Gtk.Window): self._intro_box.show() self.connect('key-press-event', self.__key_press_cb) - def _done_cb(self, box, name, color): + def _done_cb(self, box, name, age, color): self.hide() - GObject.idle_add(self._create_profile_cb, name, color) + GObject.idle_add(self._create_profile_cb, name, age, color) - def _create_profile_cb(self, name, color): - create_profile(name, color) + def _create_profile_cb(self, name, age, color): + create_profile(name, age, color) Gtk.main_quit() return False diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am index ba29062..df8f961 100644 --- a/src/jarabe/journal/Makefile.am +++ b/src/jarabe/journal/Makefile.am @@ -15,4 +15,6 @@ sugar_PYTHON = \ model.py \ objectchooser.py \ palettes.py \ - volumestoolbar.py + processdialog.py \ + volumestoolbar.py \ + webdavmanager.py diff --git a/src/jarabe/journal/expandedentry.py b/src/jarabe/journal/expandedentry.py index 21c0672..0170d0b 100644 --- a/src/jarabe/journal/expandedentry.py +++ b/src/jarabe/journal/expandedentry.py @@ -160,11 +160,30 @@ class ExpandedEntry(Gtk.EventBox): self._buddy_list.pack_start(self._create_buddy_list(), False, False, style.DEFAULT_SPACING) - description = metadata.get('description', '') + # TRANS: Do not translate the """%s""". + uploader_nick_text = self.__create_text_description( + _('Source XO Nick :: \n%s'), metadata.get('uploader-nick', '')) + + # TRANS: Do not translate the """%s""". + uploader_serial_text = self.__create_text_description( + _('Source XO Serial Number :: \n%s'), metadata.get('uploader-serial', '')) + + # TRANS: Do not translate the """%s""". + misc_info_text = self.__create_text_description( + _('Misellaneous Information :: \n%s'), metadata.get('description', '')) + + description = uploader_nick_text + uploader_serial_text + misc_info_text self._description.get_buffer().set_text(description) + tags = metadata.get('tags', '') self._tags.get_buffer().set_text(tags) + def __create_text_description(self, heading, value): + if (value == '') or (value is None): + return '' + + return ((heading % value) + '\n\n') + def _create_keep_icon(self): keep_icon = KeepIcon() keep_icon.connect('toggled', self._keep_icon_toggled_cb) @@ -396,18 +415,20 @@ class ExpandedEntry(Gtk.EventBox): needs_update = True if needs_update: - if self._metadata.get('mountpoint', '/') == '/': - model.write(self._metadata, update_mtime=False) - else: - old_file_path = os.path.join(self._metadata['mountpoint'], - model.get_file_name(old_title, - self._metadata['mime_type'])) - model.write(self._metadata, file_path=old_file_path, - update_mtime=False) + from jarabe.journal.journalactivity import get_journal + self._metadata['mountpoint'] = \ + get_journal().get_detail_toolbox().get_mount_point() + + model.update_only_metadata_and_preview_files_and_return_file_paths(self._metadata) self._update_title_sid = None def _keep_icon_toggled_cb(self, keep_icon): + # If it is a locally-mounted remote-share, return without doing + # any processing. + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if keep_icon.get_active(): self._metadata['keep'] = 1 else: diff --git a/src/jarabe/journal/journalactivity.py b/src/jarabe/journal/journalactivity.py index 4bb68fd..b2ffae0 100644 --- a/src/jarabe/journal/journalactivity.py +++ b/src/jarabe/journal/journalactivity.py @@ -19,6 +19,7 @@ import logging from gettext import gettext as _ import uuid +from gi.repository import GObject from gi.repository import Gtk from gi.repository import Gdk from gi.repository import GdkX11 @@ -27,7 +28,8 @@ import statvfs import os from sugar3.graphics.window import Window -from sugar3.graphics.alert import ErrorAlert +from sugar3.graphics.icon import Icon +from sugar3.graphics.alert import Alert, ErrorAlert, ConfirmationAlert from sugar3.bundle.bundle import ZipExtractException, RegistrationException from sugar3 import env @@ -37,7 +39,9 @@ from gi.repository import SugarExt from jarabe.model import bundleregistry from jarabe.journal.journaltoolbox import MainToolbox, DetailToolbox +from jarabe.journal.journaltoolbox import EditToolbox from jarabe.journal.listview import ListView +from jarabe.journal.listmodel import ListModel from jarabe.journal.detailview import DetailView from jarabe.journal.volumestoolbar import VolumesToolbar from jarabe.journal import misc @@ -46,6 +50,7 @@ from jarabe.journal.objectchooser import ObjectChooser from jarabe.journal.modalalert import ModalAlert from jarabe.journal import model from jarabe.journal.journalwindow import JournalWindow +from jarabe.journal.journalwindow import show_normal_cursor J_DBUS_SERVICE = 'org.laptop.Journal' @@ -56,6 +61,7 @@ _SPACE_TRESHOLD = 52428800 _BUNDLE_ID = 'org.laptop.JournalActivity' _journal = None +_mount_point = None class JournalActivityDBusService(dbus.service.Object): @@ -124,8 +130,33 @@ class JournalActivity(JournalWindow): self._list_view = None self._detail_view = None self._main_toolbox = None + self._edit_toolbox = None self._detail_toolbox = None self._volumes_toolbar = None + self._editing_mode = False + self._alert = Alert() + + self._error_alert = Alert() + icon = Icon(icon_name='dialog-ok') + self._error_alert.add_button(Gtk.ResponseType.OK, _('Ok'), icon) + icon.show() + + self._confirmation_alert = Alert() + icon = Icon(icon_name='dialog-cancel') + self._confirmation_alert.add_button(Gtk.ResponseType.CANCEL, _('Stop'), icon) + icon.show() + icon = Icon(icon_name='dialog-ok') + self._confirmation_alert.add_button(Gtk.ResponseType.OK, _('Continue'), icon) + icon.show() + + self._current_alert = None + self.setup_handlers_for_alert_actions() + + self._info_alert = None + self._selected_entries = [] + self._bundle_installation_allowed = True + + set_mount_point('/') self._setup_main_view() self._setup_secondary_view() @@ -151,10 +182,17 @@ class JournalActivity(JournalWindow): self._check_available_space() def __volume_error_cb(self, gobject, message, severity): - alert = ErrorAlert(title=severity, msg=message) - alert.connect('response', self.__alert_response_cb) - self.add_alert(alert) - alert.show() + self.update_title_and_message(self._error_alert, severity, + message) + self._callback = None + self._data = None + self.update_alert(self._error_alert) + + def _show_alert(self, message, severity): + self.__volume_error_cb(None, message, severity) + + def _volume_error_cb(self, gobject, message, severity): + self.update_error_alert(severity, message, None, None) def __alert_response_cb(self, alert, response_id): self.remove_alert(alert) @@ -196,11 +234,14 @@ class JournalActivity(JournalWindow): self._main_toolbox.search_entry.connect('icon-press', self.__search_icon_pressed_cb) self._main_toolbox.set_mount_point('/') + #search_toolbar.set_mount_point('/') + set_mount_point('/') def _setup_secondary_view(self): self._secondary_view = Gtk.VBox() self._detail_toolbox = DetailToolbox() + self._detail_toolbox.set_mount_point('/') self._detail_toolbox.connect('volume-error', self.__volume_error_cb) @@ -240,9 +281,16 @@ class JournalActivity(JournalWindow): self.connect('key-press-event', self._key_press_event_cb) def show_main_view(self): - if self.toolbar_box != self._main_toolbox: - self.set_toolbar_box(self._main_toolbox) - self._main_toolbox.show() + if self._editing_mode: + self._toolbox = EditToolbox() + + # TRANS: Do not translate the "%d" + self._toolbox.set_total_number_of_entries(self.get_total_number_of_entries()) + else: + self._toolbox = self._main_toolbox + + self.set_toolbar_box(self._toolbox) + self._toolbox.show() if self.canvas != self._main_view: self.set_canvas(self._main_view) @@ -277,6 +325,10 @@ class JournalActivity(JournalWindow): def __volume_changed_cb(self, volume_toolbar, mount_point): logging.debug('Selected volume: %r.', mount_point) self._main_toolbox.set_mount_point(mount_point) + set_mount_point(mount_point) + + # Also, need to update the mount-point for Detail-View. + self._detail_toolbox.set_mount_point(mount_point) def __model_created_cb(self, sender, **kwargs): self._check_for_bundle(kwargs['object_id']) @@ -301,6 +353,9 @@ class JournalActivity(JournalWindow): self._list_view.update_dates() def _check_for_bundle(self, object_id): + if not self._bundle_installation_allowed: + return + registry = bundleregistry.get_registry() metadata = model.get(object_id) @@ -336,6 +391,9 @@ class JournalActivity(JournalWindow): metadata['bundle_id'] = bundle.get_bundle_id() model.write(metadata) + def set_bundle_installation_allowed(self, allowed): + self._bundle_installation_allowed = allowed + def __window_state_event_cb(self, window, event): logging.debug('window_state_event_cb %r', self) if event.changed_mask & Gdk.WindowState.ICONIFIED: @@ -378,6 +436,105 @@ class JournalActivity(JournalWindow): self.reveal() self.show_main_view() + def switch_to_editing_mode(self, switch): + # (re)-switch, only if not already. + if (switch) and (not self._editing_mode): + self._editing_mode = True + self.get_list_view().disable_drag_and_copy() + self.show_main_view() + elif (not switch) and (self._editing_mode): + self._editing_mode = False + self.get_list_view().enable_drag_and_copy() + self.show_main_view() + + def get_list_view(self): + return self._list_view + + def setup_handlers_for_alert_actions(self): + self._error_alert.connect('response', + self.__check_for_alert_action) + self._confirmation_alert.connect('response', + self.__check_for_alert_action) + + def __check_for_alert_action(self, alert, response_id): + self.hide_alert() + if self._callback is not None: + GObject.idle_add(self._callback, self._data, + response_id) + + def update_title_and_message(self, alert, title, message): + alert.props.title = title + alert.props.msg = message + + def update_alert(self, alert): + if self._current_alert is None: + self.add_alert(alert) + elif self._current_alert != alert: + self.remove_alert(self._current_alert) + self.add_alert(alert) + + self.remove_alert(self._current_alert) + self.add_alert(alert) + self._current_alert = alert + self._current_alert.show() + show_normal_cursor() + + def hide_alert(self): + if self._current_alert is not None: + self._current_alert.hide() + + def update_info_alert(self, title, message): + self.get_toolbar_box().display_running_status_in_multi_select(title, message) + + def update_error_alert(self, title, message, callback, data): + self.update_title_and_message(self._error_alert, title, + message) + self._callback = callback + self._data = data + self.update_alert(self._error_alert) + + def update_confirmation_alert(self, title, message, callback, + data): + self.update_title_and_message(self._confirmation_alert, title, + message) + self._callback = callback + self._data = data + self.update_alert(self._confirmation_alert) + + def update_progress(self, fraction): + self.get_toolbar_box().update_progress(fraction) + + def get_metadata_list(self, selected_state): + metadata_list = [] + + list_view_model = self.get_list_view().get_model() + for index in range(0, len(list_view_model)): + metadata = list_view_model.get_metadata(index) + metadata_selected = \ + list_view_model.get_selected_value(metadata['uid']) + + if ( (selected_state and metadata_selected) or \ + ((not selected_state) and (not metadata_selected)) ): + metadata_list.append(metadata) + + return metadata_list + + def get_total_number_of_entries(self): + list_view_model = self.get_list_view().get_model() + return len(list_view_model) + + def is_editing_mode_present(self): + return self._editing_mode + + def get_volumes_toolbar(self): + return self._volumes_toolbar + + def get_toolbar_box(self): + return self._toolbox + + def get_detail_toolbox(self): + return self._detail_toolbox + def get_journal(): global _journal @@ -389,3 +546,11 @@ def get_journal(): def start(): get_journal() + + +def set_mount_point(mount_point): + global _mount_point + _mount_point = mount_point + +def get_mount_point(): + return _mount_point diff --git a/src/jarabe/journal/journaltoolbox.py b/src/jarabe/journal/journaltoolbox.py index 09d8a31..cb19f65 100644 --- a/src/jarabe/journal/journaltoolbox.py +++ b/src/jarabe/journal/journaltoolbox.py @@ -16,6 +16,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA from gettext import gettext as _ +from gettext import ngettext import logging from datetime import datetime, timedelta import os @@ -26,6 +27,7 @@ from gi.repository import GObject from gi.repository import Gio import glib from gi.repository import Gtk +from gi.repository import Gdk from sugar3.graphics.palette import Palette from sugar3.graphics.toolbarbox import ToolbarBox @@ -45,8 +47,9 @@ from jarabe.journal import misc from jarabe.journal import model from jarabe.journal.palettes import ClipboardMenu from jarabe.journal.palettes import VolumeMenu -from jarabe.journal import journalwindow +from jarabe.journal import journalwindow, palettes +COPY_MENU_HELPER = palettes.get_copy_menu_helper() _AUTOSEARCH_TIMEOUT = 1000 @@ -74,6 +77,10 @@ class MainToolbox(ToolbarBox): def __init__(self): ToolbarBox.__init__(self) + self._info_widget = MultiSelectEntriesInfoWidget() + self.add(self._info_widget) + self._info_widget.hide() + self._mount_point = None self.search_entry = iconentry.IconEntry() @@ -123,6 +130,12 @@ class MainToolbox(ToolbarBox): self.refresh_filters() + def update_progress(self, fraction): + self._info_widget.update_progress(fraction) + + def hide_info_widget(self): + self._info_widget.hide() + def _get_when_search_combo(self): when_search = ComboBox() when_search.append_item(_ACTION_ANYTIME, _('Anytime')) @@ -390,11 +403,30 @@ class DetailToolbox(ToolbarBox): separator.show() erase_button = ToolButton('list-remove') + self._erase_button = erase_button erase_button.set_tooltip(_('Erase')) erase_button.connect('clicked', self._erase_button_clicked_cb) self.toolbar.insert(erase_button, -1) erase_button.show() + def set_mount_point(self, mount_point): + self._mount_point = mount_point + self.set_sensitivity_of_icons() + + def get_mount_point(self): + return self._mount_point + + def set_sensitivity_of_icons(self): + mount_point = self.get_mount_point() + if model.is_mount_point_for_locally_mounted_remote_share(mount_point): + sensitivity = False + else: + sensitivity = True + + self._resume.set_sensitive(sensitivity) + self._duplicate.set_sensitive(sensitivity) + self._erase_button.set_sensitive(sensitivity) + def set_metadata(self, metadata): self._metadata = metadata self._refresh_copy_palette() @@ -452,39 +484,11 @@ class DetailToolbox(ToolbarBox): palette.menu.remove(menu_item) menu_item.destroy() - clipboard_menu = ClipboardMenu(self._metadata) - clipboard_menu.set_image(Icon(icon_name='toolbar-edit', - icon_size=Gtk.IconSize.MENU)) - clipboard_menu.connect('volume-error', self.__volume_error_cb) - palette.menu.append(clipboard_menu) - clipboard_menu.show() - - if self._metadata['mountpoint'] != '/': - client = GConf.Client.get_default() - color = XoColor(client.get_string('/desktop/sugar/user/color')) - journal_menu = VolumeMenu(self._metadata, _('Journal'), '/') - journal_menu.set_image(Icon(icon_name='activity-journal', - xo_color=color, - icon_size=Gtk.IconSize.MENU)) - journal_menu.connect('volume-error', self.__volume_error_cb) - palette.menu.append(journal_menu) - journal_menu.show() - - volume_monitor = Gio.VolumeMonitor.get() - icon_theme = Gtk.IconTheme.get_default() - for mount in volume_monitor.get_mounts(): - if self._metadata['mountpoint'] == mount.get_root().get_path(): - continue - volume_menu = VolumeMenu(self._metadata, mount.get_name(), - mount.get_root().get_path()) - for name in mount.get_icon().props.names: - if icon_theme.has_icon(name): - volume_menu.set_image(Icon(icon_name=name, - icon_size=Gtk.IconSize.MENU)) - break - volume_menu.connect('volume-error', self.__volume_error_cb) - palette.menu.append(volume_menu) - volume_menu.show() + COPY_MENU_HELPER.insert_copy_to_menu_items(palette.menu, + [self._metadata], + show_editing_alert=False, + show_progress_info_alert=False, + batch_mode=False) def _refresh_duplicate_palette(self): color = misc.get_icon_color(self._metadata) @@ -524,6 +528,270 @@ class DetailToolbox(ToolbarBox): menu_item.show() +class EditToolbox(ToolbarBox): + def __init__(self): + ToolbarBox.__init__(self) + + self.toolbar.add(SelectNoneButton()) + self.toolbar.add(SelectAllButton()) + + self.toolbar.add(Gtk.SeparatorToolItem()) + + self.toolbar.add(BatchEraseButton()) + self.toolbar.add(BatchCopyButton()) + + self.toolbar.add(Gtk.SeparatorToolItem()) + + self._multi_select_info_widget = MultiSelectEntriesInfoWidget() + self.toolbar.add(self._multi_select_info_widget) + + self.show_all() + self.toolbar.show_all() + + def process_new_selected_entry_in_multi_select(self): + GObject.idle_add(self._multi_select_info_widget.update_text, + '', '', True, True) + + def process_new_deselected_entry_in_multi_select(self): + GObject.idle_add(self._multi_select_info_widget.update_text, + '', '', False, True) + + def display_running_status_in_multi_select(self, primary_info, + secondary_info): + GObject.idle_add(self._multi_select_info_widget.update_text, + primary_info, secondary_info, + None, None) + + def display_already_selected_entries_status(self): + GObject.idle_add(self._multi_select_info_widget.update_text, + '', '', True, False) + + def set_total_number_of_entries(self, total): + self._multi_select_info_widget.set_total_number_of_entries(total) + + def get_current_entry_number(self): + return self._multi_select_info_widget.get_current_entry_number() + + def update_progress(self, fraction): + self._multi_select_info_widget.update_progress(fraction) + + +class SelectNoneButton(ToolButton): + def __init__(self): + ToolButton.__init__(self, 'select-none') + self.props.tooltip = _('Deselect all') + + self.connect('clicked', self.__do_deselect_all) + + def __do_deselect_all(self, widget_clicked): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.get_list_view()._selected_entries = 0 + journal.switch_to_editing_mode(False) + journal.get_list_view().inhibit_refresh(False) + journal.get_list_view().refresh() + + +class SelectAllButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'select-all') + palettes.ActionItem.__init__(self, '', [], + show_editing_alert=False, + show_progress_info_alert=False, + batch_mode=True, + auto_deselect_source_entries=True, + need_to_popup_options=False, + operate_on_deselected_entries=True, + show_not_completed_ops_info=False) + self.props.tooltip = _('Select all') + + def _get_actionable_signal(self): + return 'clicked' + + def _get_editing_alert_operation(self): + return _('Select all') + + def _get_info_alert_title(self): + return _('Selecting') + + def _get_post_selection_alert_message_entries_len(self): + return self._model_len + + def _get_post_selection_alert_message(self, entries_len): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + return ngettext('You have selected %d entry.', + 'You have selected %d entries.', + entries_len) % (entries_len,) + + def _operate(self, metadata): + # Nothing specific needs to be done. + # The checkboxes are unchecked as part of the toggling of any + # operation that operates on selected entries. + + # This is sync-operation. Thus, call the callback. + self._post_operate_per_metadata_per_action(metadata) + + +class BatchEraseButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'edit-delete') + palettes.ActionItem.__init__(self, '', [], + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True, + auto_deselect_source_entries=True, + need_to_popup_options=False, + operate_on_deselected_entries=False, + show_not_completed_ops_info=True) + self.props.tooltip = _('Erase') + + # De-sensitize Batch-Erase button, for locally-mounted-remote-shares. + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + self.set_sensitive(False) + + def _get_actionable_signal(self): + return 'clicked' + + def _get_editing_alert_title(self): + return _('Erase') + + def _get_editing_alert_message(self, entries_len): + return ngettext('Do you want to erase %d entry?', + 'Do you want to erase %d entries?', + entries_len) % (entries_len) + + def _get_editing_alert_operation(self): + return _('Erase') + + def _get_info_alert_title(self): + return _('Erasing') + + def _operate(self, metadata): + model.delete(metadata['uid']) + + # This is sync-operation. Thus, call the callback. + self._post_operate_per_metadata_per_action(metadata) + + +class BatchCopyButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'edit-copy') + palettes.ActionItem.__init__(self, '', [], + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True, + auto_deselect_source_entries=False, + need_to_popup_options=True, + operate_on_deselected_entries=False, + show_not_completed_ops_info=False) + + self.props.tooltip = _('Copy') + + self._metadata_list = None + self._fill_and_pop_up_options(None) + + def _get_actionable_signal(self): + return 'clicked' + + def _fill_and_pop_up_options(self, widget_clicked): + for child in self.props.palette.menu.get_children(): + self.props.palette.menu.remove(child) + + COPY_MENU_HELPER.insert_copy_to_menu_items(self.props.palette.menu, + [], + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True) + if widget_clicked is not None: + self.props.palette.popup(immediate=True, state=1) + + +class MultiSelectEntriesInfoWidget(Gtk.ToolItem): + def __init__(self): + Gtk.ToolItem.__init__(self) + + self._box = Gtk.VBox() + self._selected_entries = 0 + + self._label = Gtk.Label() + self._box.pack_start(self._label, True, True, 0) + + self._progress_label = Gtk.Label() + self._box.pack_start(self._progress_label, True, True, 0) + + self.add(self._box) + + self.show_all() + self._box.show_all() + self._progress_label.hide() + + def set_total_number_of_entries(self, total): + self._total = total + + def update_progress(self, fraction): + percent = '%.02f' % (fraction * 100) + + # TRANS: Do not translate %.02f + text = '%.02f%% complete' % (fraction * 100) + if (str(percent) != '100.00') and (str(percent).endswith('00')): + self._progress_label.set_text(text) + self._progress_label.show() + self.show_all() + Gdk.Window.process_all_updates() + else: + self._progress_label.hide() + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + self.hide() + + def update_text(self, primary_text, secondary_text, special_action, + update_selected_entries): + # If "special_action" is None, + # we need to display the info, conveyed by + # "primary_message" and "secondary_message" + # + # If "special_action" is True, + # a new entry has been selected. + # + # If "special_action" is False, + # an enrty has been deselected. + if special_action == None: + self._label.set_text(primary_text + secondary_text) + self._label.show() + else: + if update_selected_entries: + if special_action == True: + self._selected_entries = self._selected_entries + 1 + elif special_action == False: + self._selected_entries = self._selected_entries - 1 + + # TRANS: Do not translate the two "%d". + message = _('Selected %d of %d') % (self._selected_entries, + self._total) + + # Only show the "selected x of y" for "Select All", or + # "Deselect All", or if the user checked/unchecked a + # checkbox. + from jarabe.journal.palettes import get_current_action_item + current_action_item = get_current_action_item() + if current_action_item == None or \ + isinstance(current_action_item, SelectAllButton) or \ + isinstance(current_action_item, SelectNoneButton): + self._label.set_text(message) + self._label.show() + + Gdk.Window.process_all_updates() + + def get_current_entry_number(self): + return self._selected_entries + + class SortingButton(ToolButton): __gtype_name__ = 'JournalSortingButton' diff --git a/src/jarabe/journal/journalwindow.py b/src/jarabe/journal/journalwindow.py index 776a495..8fcecaf 100644 --- a/src/jarabe/journal/journalwindow.py +++ b/src/jarabe/journal/journalwindow.py @@ -15,6 +15,8 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +from gi.repository import Gdk + from sugar3.graphics.window import Window _journal_window = None @@ -31,3 +33,46 @@ class JournalWindow(Window): def get_journal_window(): return _journal_window + + +def set_widgets_active_state(active_state): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.get_toolbar_box().set_sensitive(active_state) + journal.get_list_view().set_sensitive(active_state) + journal.get_volumes_toolbar().set_sensitive(active_state) + + +def show_waiting_cursor(): + # Only show waiting-cursor, if this is the batch-mode. + + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + return + + _journal_window.get_root_window().set_cursor(Gdk.Cursor.new(Gdk.CursorType.WATCH)) + + +def freeze_ui(): + # Only freeze, if this is the batch-mode. + + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + return + + show_waiting_cursor() + + set_widgets_active_state(False) + + +def show_normal_cursor(): + _journal_window.get_root_window().set_cursor(Gdk.Cursor.new(Gdk.CursorType.LEFT_PTR)) + + +def unfreeze_ui(): + # Unfreeze, irrespective of whether this is the batch mode. + + set_widgets_active_state(True) + + show_normal_cursor() diff --git a/src/jarabe/journal/keepicon.py b/src/jarabe/journal/keepicon.py index 16e3a57..9c7b7d5 100644 --- a/src/jarabe/journal/keepicon.py +++ b/src/jarabe/journal/keepicon.py @@ -22,6 +22,8 @@ from sugar3.graphics.icon import Icon from sugar3.graphics import style from sugar3.graphics.xocolor import XoColor +from jarabe.journal import model + class KeepIcon(Gtk.ToggleButton): def __init__(self): @@ -37,6 +39,9 @@ class KeepIcon(Gtk.ToggleButton): self.connect('enter-notify-event', self.__enter_notify_event_cb) def __toggled_cb(self, widget): + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if self.get_active(): client = GConf.Client.get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) @@ -47,9 +52,15 @@ class KeepIcon(Gtk.ToggleButton): self._icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() def __enter_notify_event_cb(self, icon, event): + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if not self.get_active(): self._icon.props.fill_color = style.COLOR_BUTTON_GREY.get_svg() def __leave_notify_event_cb(self, icon, event): + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if not self.get_active(): self._icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() diff --git a/src/jarabe/journal/listmodel.py b/src/jarabe/journal/listmodel.py index b98d01c..a5bb7b0 100644 --- a/src/jarabe/journal/listmodel.py +++ b/src/jarabe/journal/listmodel.py @@ -54,6 +54,7 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): COLUMN_BUDDY_1 = 9 COLUMN_BUDDY_2 = 10 COLUMN_BUDDY_3 = 11 + COLUMN_SELECT = 12 _COLUMN_TYPES = { COLUMN_UID: str, @@ -68,6 +69,7 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): COLUMN_BUDDY_1: object, COLUMN_BUDDY_3: object, COLUMN_BUDDY_2: object, + COLUMN_SELECT: bool, } _PAGE_SIZE = 10 @@ -79,6 +81,8 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): self._cached_row = None self._result_set = model.find(query, ListModel._PAGE_SIZE) self._temp_drag_file_path = None + self._selected = {} + self._uid_metadata_assoc = {} # HACK: The view will tell us that it is resizing so the model can # avoid hitting D-Bus and disk. @@ -248,3 +252,22 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): return True return False + + def update_uid_metadata_assoc(self, uid, metadata): + self._uid_metadata_assoc[uid] = metadata + + def set_selected_value(self, uid, value): + if value == False: + del self._selected[uid] + elif value == True: + self._selected[uid] = value + + def get_selected_value(self, uid): + if self._selected.has_key(uid): + return True + else: + return False + + def get_in_memory_metadata(self, path): + uid = self[path][ListModel.COLUMN_UID] + return self._uid_metadata_assoc[uid] diff --git a/src/jarabe/journal/listview.py b/src/jarabe/journal/listview.py index 5b2c5ab..35c8092 100644 --- a/src/jarabe/journal/listview.py +++ b/src/jarabe/journal/listview.py @@ -67,7 +67,8 @@ class BaseListView(Gtk.Bin): 'clear-clicked': (GObject.SignalFlags.RUN_FIRST, None, ([])), } - def __init__(self): + def __init__(self, is_object_chooser): + self._is_object_chooser = is_object_chooser self._query = {} self._model = None self._progress_bar = None @@ -100,11 +101,11 @@ class BaseListView(Gtk.Bin): self._title_column = None self.sort_column = None self._add_columns() + self._inhibit_refresh = False + self._selected_entries = 0 + + self.enable_drag_and_copy() - self.tree_view.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK, - [('text/uri-list', 0, 0), - ('journal-object-id', 0, 0)], - Gdk.DragAction.COPY) # Auto-update stuff self._fully_obscured = True @@ -116,6 +117,15 @@ class BaseListView(Gtk.Bin): model.updated.connect(self.__model_updated_cb) model.deleted.connect(self.__model_deleted_cb) + def enable_drag_and_copy(self): + self.tree_view.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK, + [('text/uri-list', 0, 0), + ('journal-object-id', 0, 0)], + Gdk.DragAction.COPY) + + def disable_drag_and_copy(self): + self.tree_view.unset_rows_drag_source() + def __model_created_cb(self, sender, signal, object_id): if self._is_new_item_visible(object_id): self._set_dirty() @@ -136,6 +146,17 @@ class BaseListView(Gtk.Bin): return object_id.startswith(self._query['mountpoints'][0]) def _add_columns(self): + if not self._is_object_chooser: + cell_select = CellRendererToggle(self.tree_view) + cell_select.connect('clicked', self.__cell_select_clicked_cb) + + column = Gtk.TreeViewColumn() + column.props.sizing = Gtk.TreeViewColumnSizing.FIXED + column.props.fixed_width = cell_select.props.width + column.pack_start(cell_select, True) + column.set_cell_data_func(cell_select, self.__select_set_data_cb) + self.tree_view.append_column(column) + cell_favorite = CellRendererFavorite(self.tree_view) cell_favorite.connect('clicked', self.__favorite_clicked_cb) @@ -248,8 +269,30 @@ class BaseListView(Gtk.Bin): def __favorite_set_data_cb(self, column, cell, tree_model, tree_iter, data): - favorite = tree_model[tree_iter][ListModel.COLUMN_FAVORITE] - if favorite: + # Instead of querying the favorite-status from the "cached" + # entries in listmodel, hit the DS, and retrieve the persisted + # favorite-status. + # This solves the issue in "Multi-Select", wherein the + # listview is inhibited from refreshing. Now, if the user + # clicks favorite-star-icon(s), the change(s) is(are) written + # to the DS, but no refresh takes place. Thus, in order to have + # the change(s) reflected on the UI, we need to hit the DS for + # querying the favorite-status (instead of relying on the + # cached-listmodel. + uid = tree_model[tree_iter][ListModel.COLUMN_UID] + if uid is None: + return + + try: + metadata = model.get(uid) + except: + return + + favorite = None + if 'keep' in metadata.keys(): + favorite = str(metadata['keep']) + + if favorite == '1': client = GConf.Client.get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) cell.props.xo_color = color @@ -257,6 +300,11 @@ class BaseListView(Gtk.Bin): cell.props.xo_color = None def __favorite_clicked_cb(self, cell, path): + # If this is a remote-share, return without doing any + # processing. + if model.is_current_mount_point_for_remote_share(model.LIST_VIEW): + return + row = self._model[path] metadata = model.get(row[ListModel.COLUMN_UID]) if not model.is_editable(metadata): @@ -265,7 +313,94 @@ class BaseListView(Gtk.Bin): metadata['keep'] = '0' else: metadata['keep'] = '1' - model.write(metadata, update_mtime=False) + + from jarabe.journal.journalactivity import get_mount_point + metadata['mountpoint'] = get_mount_point() + + model.update_only_metadata_and_preview_files_and_return_file_paths(metadata) + self.__redraw_view_if_necessary() + + def __select_set_data_cb(self, column, cell, tree_model, tree_iter, + data): + uid = tree_model[tree_iter][ListModel.COLUMN_UID] + if uid is None: + return + + # Hack to associate the cell with the metadata, so that it (the + # cell) is available offline as well (example during + # batch-operations, when the processing has to be done, without + # actually clicking any cell. + try: + metadata = model.get(uid) + except: + # https://dev.laptop.org.au/issues/1119 + # http://bugs.sugarlabs.org/ticket/3344 + # Occurs, when copying entries from journal to pen-drive. + # Simply swallow the exception, and return, as this too, + # like the above case, does not have any impact on the + # functionality. + return + + metadata['cell'] = cell + tree_model.update_uid_metadata_assoc(uid, metadata) + + self.do_ui_select_change(metadata) + + def __cell_select_clicked_cb(self, cell, path): + row = self._model[path] + treeiter = self._model.get_iter(path) + metadata = model.get(row[ListModel.COLUMN_UID]) + self.do_backend_select_change(metadata) + + def do_ui_select_change(self, metadata): + tree_model = self.get_model() + selected = tree_model.get_selected_value(metadata['uid']) + + if 'cell' in metadata.keys(): + cell = metadata['cell'] + if selected: + cell.props.icon_name = 'emblem-checked' + else: + cell.props.icon_name = 'emblem-unchecked' + + def do_backend_select_change(self, metadata): + uid = metadata['uid'] + selected = self._model.get_selected_value(uid) + + self._model.set_selected_value(uid, not selected) + self._process_new_selected_status(not selected) + + def _process_new_selected_status(self, new_status): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + journal_toolbar_box = journal.get_toolbar_box() + + self.__redraw_view_if_necessary() + + if new_status == False: + self._selected_entries = self._selected_entries - 1 + journal_toolbar_box.process_new_deselected_entry_in_multi_select() + GObject.idle_add(self._post_backend_processing) + else: + self._selected_entries = self._selected_entries + 1 + journal.get_list_view().inhibit_refresh(True) + journal.switch_to_editing_mode(True) + + # For the case, when we are switching to editing-mode. + # The previous call won't actually redraw, as we are not in + # editing-mode that time. + self.__redraw_view_if_necessary() + + journal.get_toolbar_box().process_new_selected_entry_in_multi_select() + + def _post_backend_processing(self): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + if self._selected_entries == 0: + journal.switch_to_editing_mode(False) + journal.get_list_view().inhibit_refresh(False) + journal.get_list_view().refresh() def update_with_query(self, query_dict): logging.debug('ListView.update_with_query') @@ -282,6 +417,11 @@ class BaseListView(Gtk.Bin): self.refresh() def refresh(self): + if not self._inhibit_refresh: + self.set_sensitive(True) + self.proceed_with_refresh() + + def proceed_with_refresh(self): logging.debug('ListView.refresh query %r', self._query) self._stop_progress_bar() @@ -482,6 +622,64 @@ class BaseListView(Gtk.Bin): self.update_dates() return True + def get_model(self): + return self._model + + def inhibit_refresh(self, inhibit): + self._inhibit_refresh = inhibit + + def __redraw_view_if_necessary(self): + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + return + + # First, get the total number of entries, for which the + # batch-operation is under progress. + from jarabe.journal.palettes import get_current_action_item + + current_action_item = get_current_action_item() + if current_action_item is None: + # A single checkbox has been clicked/unclicked. + self.__redraw() + return + + total_items = current_action_item.get_number_of_entries_to_operate_upon() + + # Then, get the current entry being processed. + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + current_entry_number = journal.get_toolbar_box().get_current_entry_number() + + # Redraw, if "current_entry_number" is 10. + if current_entry_number == 10: + self.__log(current_entry_number, total_items) + self.__redraw() + return + + # Redraw, if this is the last entry. + if current_entry_number == total_items: + self.__log(current_entry_number, total_items) + self.__redraw() + return + + # Redraw, if this is the 20% interval. + twenty_percent_of_total_items = total_items / 5 + if twenty_percent_of_total_items < 10: + return + + if (current_entry_number % twenty_percent_of_total_items) == 0: + self.__log(current_entry_number, total_items) + self.__redraw() + return + + def __log(self, current_entry_number, total_items): + pass + + def __redraw(self): + tree_view_window = self.tree_view.get_bin_window() + tree_view_window.hide() + tree_view_window.show() + class ListView(BaseListView): __gtype_name__ = 'JournalListView' @@ -497,8 +695,8 @@ class ListView(BaseListView): ([])), } - def __init__(self): - BaseListView.__init__(self) + def __init__(self, is_object_chooser=False): + BaseListView.__init__(self, is_object_chooser) self._is_dragging = False self.tree_view.connect('drag-begin', self.__drag_begin_cb) @@ -559,11 +757,25 @@ class ListView(BaseListView): self.emit('volume-error', message, severity) def __icon_clicked_cb(self, cell, path): + # For locally-mounted remote shares, we do not want to launch + # by clicking on the icons. + # So, check if this is a part of locally-mounted-remote share, + # and if yes, return, without doing anything. + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + return + row = self.tree_view.get_model()[path] metadata = model.get(row[ListModel.COLUMN_UID]) misc.resume(metadata) def __cell_title_edited_cb(self, cell, path, new_text): + from jarabe.journal.journalactivity import get_journal, \ + get_mount_point + if get_journal().is_editing_mode_present(): + return + row = self._model[path] metadata = model.get(row[ListModel.COLUMN_UID]) metadata['title'] = new_text @@ -592,6 +804,25 @@ class CellRendererFavorite(CellRendererIcon): self.props.prelit_stroke_color = prelit_color.get_stroke_color() self.props.prelit_fill_color = prelit_color.get_fill_color() + def do_render(self, cr, widget, background_area, cell_area, flags): + # If this is a remote-share, mask the "PRELIT" flag. + if model.is_current_mount_point_for_remote_share(model.LIST_VIEW): + flags = flags & (~(Gtk.CellRendererState.PRELIT)) + + CellRendererIcon.do_render(self, cr, widget, background_area, cell_area, flags) + +class CellRendererToggle(CellRendererIcon): + __gtype_name__ = 'JournalCellRendererSelect' + + def __init__(self, tree_view): + CellRendererIcon.__init__(self, tree_view) + + self.props.width = style.GRID_CELL_SIZE + self.props.height = style.GRID_CELL_SIZE + self.props.size = style.SMALL_ICON_SIZE + self.props.icon_name = 'checkbox-unchecked' + self.props.mode = Gtk.CellRendererMode.ACTIVATABLE + class CellRendererDetail(CellRendererIcon): __gtype_name__ = 'JournalCellRendererDetail' @@ -636,6 +867,11 @@ class CellRendererActivityIcon(CellRendererIcon): if not self._show_palette: return None + # Also, if we are in batch-operations mode, return 'None' + from jarabe.journal.journalactivity import get_journal + if get_journal().is_editing_mode_present(): + return None + tree_model = self.tree_view.get_model() metadata = tree_model.get_metadata(self.props.palette_invoker.path) diff --git a/src/jarabe/journal/misc.py b/src/jarabe/journal/misc.py index efd0dbe..f627c1b 100644 --- a/src/jarabe/journal/misc.py +++ b/src/jarabe/journal/misc.py @@ -40,6 +40,7 @@ from jarabe.journal.journalentrybundle import JournalEntryBundle from jarabe.journal import model from jarabe.journal import journalwindow +_NOT_AVAILABLE = _('Not available') def _get_icon_for_mime(mime_type): generic_types = mime.get_all_generic_types() @@ -312,3 +313,48 @@ def get_icon_color(metadata): return XoColor(client.get_string('/desktop/sugar/user/color')) else: return XoColor(metadata['icon-color']) + + +def get_xo_serial(): + _OFW_TREE = '/ofw' + _PROC_TREE = '/proc/device-tree' + _SN = 'serial-number' + + serial_no = None + if os.path.exists(os.path.join(_OFW_TREE, _SN)): + serial_no = read_file(os.path.join(_OFW_TREE, _SN)) + elif os.path.exists(os.path.join(_PROC_TREE, _SN)): + serial_no = read_file(os.path.join(_PROC_TREE, _SN)) + + if serial_no is None: + serial_no = _NOT_AVAILABLE + + # Remove the trailing binary character, else DBUS will crash. + return serial_no.rstrip('\x00') + + +def read_file(path): + if os.access(path, os.R_OK) == 0: + return None + + fd = open(path, 'r') + value = fd.read() + fd.close() + if value: + value = value.strip('\n') + return value + else: + logging.debug('No information in file or directory: %s', path) + return None + + +def get_nick(): + client = GConf.Client.get_default() + return client.get_string("/desktop/sugar/user/nick") + + +def get_backup_identifier(): + serial_number = get_xo_serial() + if serial_number is _NOT_AVAILABLE: + serial_number = get_nick() + return serial_number diff --git a/src/jarabe/journal/model.py b/src/jarabe/journal/model.py index 0a5b354..c9e08ec 100644 --- a/src/jarabe/journal/model.py +++ b/src/jarabe/journal/model.py @@ -16,6 +16,7 @@ import logging import os +import stat import errno import subprocess from datetime import datetime @@ -37,6 +38,8 @@ from sugar3 import dispatch from sugar3 import mime from sugar3 import util +from jarabe.journal import webdavmanager + DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' @@ -50,14 +53,99 @@ PROPERTIES = ['activity', 'activity_id', 'buddies', 'bundle_id', MIN_PAGES_TO_CACHE = 3 MAX_PAGES_TO_CACHE = 5 +WEBDAV_MOUNT_POINT = '/tmp/' +LOCAL_SHARES_MOUNT_POINT = '/var/www/web1/web/' + JOURNAL_METADATA_DIR = '.Sugar-Metadata' +LIST_VIEW = 1 +DETAIL_VIEW = 2 + _datastore = None created = dispatch.Signal() updated = dispatch.Signal() deleted = dispatch.Signal() +SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME_PATH = \ + '/desktop/sugar/network/school_server_ip_address_or_dns_name' +IS_PEER_TO_PEER_SHARING_AVAILABLE_PATH = \ + '/desktop/sugar/network/is_peer_to_peer_sharing_available' + +client = GConf.Client.get_default() +SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME = client.get_string(SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME_PATH) or '' +IS_PEER_TO_PEER_SHARING_AVAILABLE = client.get_bool(IS_PEER_TO_PEER_SHARING_AVAILABLE_PATH) + + + +def is_school_server_present(): + return not (SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME is '') + + +def is_peer_to_peer_sharing_available(): + return IS_PEER_TO_PEER_SHARING_AVAILABLE == True + + +def _get_mount_point(path): + dir_path = os.path.dirname(path) + while dir_path: + if os.path.ismount(dir_path): + return dir_path + else: + dir_path = dir_path.rsplit(os.sep, 1)[0] + return None + + +def _check_remote_sharing_mount_point(mount_point, share_type): + from jarabe.journal.journalactivity import get_journal + + mount_point_button = get_journal().get_volumes_toolbar()._get_button_for_mount_point(mount_point) + if mount_point_button._share_type == share_type: + return True + return False + + +def is_mount_point_for_school_server(mount_point): + from jarabe.journal.volumestoolbar import SHARE_TYPE_SCHOOL_SERVER + return _check_remote_sharing_mount_point(mount_point, SHARE_TYPE_SCHOOL_SERVER) + + +def is_mount_point_for_peer_share(mount_point): + from jarabe.journal.volumestoolbar import SHARE_TYPE_PEER + return _check_remote_sharing_mount_point(mount_point, SHARE_TYPE_PEER) + + +def is_current_mount_point_for_remote_share(view_type): + from jarabe.journal.journalactivity import get_journal, get_mount_point + if view_type == LIST_VIEW: + current_mount_point = get_mount_point() + elif view_type == DETAIL_VIEW: + current_mount_point = get_journal().get_detail_toolbox().get_mount_point() + + if is_mount_point_for_locally_mounted_remote_share(current_mount_point): + return True + return False + + +def extract_ip_address_or_dns_name_from_locally_mounted_remote_share_path(path): + """ + Path is of type :: + + /tmp/1.2.3.4/webdav/a.txt; OR + /tmp/this.is.dns.name/a.txt + """ + return path.split('/')[2] + + +def is_mount_point_for_locally_mounted_remote_share(mount_point): + """ + The mount-point can be either of the ip-Address, or the DNS name. + More importantly, whatever the "name" be, it does NOT have a + forward-slash. + """ + return mount_point.find(WEBDAV_MOUNT_POINT) == 0 + + class _Cache(object): __gtype_name__ = 'model_Cache' @@ -422,6 +510,127 @@ class InplaceResultSet(BaseResultSet): return +class RemoteShareResultSet(object): + def __init__(self, ip_address_or_dns_name, query): + self._ip_address_or_dns_name = ip_address_or_dns_name + self._file_list = [] + + self.ready = dispatch.Signal() + self.progress = dispatch.Signal() + + # First time, query is none. + if query is None: + return + + query_text = query.get('query', '') + if query_text.startswith('"') and query_text.endswith('"'): + self._regex = re.compile('*%s*' % query_text.strip(['"'])) + elif query_text: + expression = '' + for word in query_text.split(' '): + expression += '(?=.*%s.*)' % word + self._regex = re.compile(expression, re.IGNORECASE) + else: + self._regex = None + + if query.get('timestamp', ''): + self._date_start = int(query['timestamp']['start']) + self._date_end = int(query['timestamp']['end']) + else: + self._date_start = None + self._date_end = None + + self._mime_types = query.get('mime_type', []) + + self._sort = query.get('order_by', ['+timestamp'])[0] + + def setup(self): + try: + metadata_list_complete = webdavmanager.get_remote_webdav_share_metadata(self._ip_address_or_dns_name) + except Exception, e: + metadata_list_complete = [] + + for metadata in metadata_list_complete: + + add_to_list = False + if self._regex is not None: + for f in ['fulltext', 'title', + 'description', 'tags']: + if f in metadata and \ + self._regex.match(metadata[f]): + add_to_list = True + break + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._date_start is not None: + if metadata['timestamp'] > self._date_start: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._date_end is not None: + if metadata['timestamp'] < self._date_end: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._mime_types: + mime_type = metadata['mime_type'] + if mime_type in self._mime_types: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + # If control reaches here, the current metadata has passed + # out all filter-tests. + file_info = (metadata['timestamp'], + metadata['creation_time'], + metadata['filesize'], + metadata) + self._file_list.append(file_info) + + if self._sort[1:] == 'filesize': + keygetter = itemgetter(2) + elif self._sort[1:] == 'creation_time': + keygetter = itemgetter(1) + else: + # timestamp + keygetter = itemgetter(0) + + self._file_list.sort(lambda a, b: cmp(b, a), + key=keygetter, + reverse=(self._sort[0] == '-')) + + self.ready.send(self) + + def get_length(self): + return len(self._file_list) + + length = property(get_length) + + def seek(self, position): + self._position = position + + def read(self): + modified_timestamp, creation_timestamp, filesize, metadata = self._file_list[self._position] + return metadata + + def stop(self): + self._stopped = True + + def _get_file_metadata(path, stat, fetch_preview=True): """Return the metadata from the corresponding file. @@ -434,9 +643,13 @@ def _get_file_metadata(path, stat, fetch_preview=True): metadata = _get_file_metadata_from_json(dir_path, filename, fetch_preview) if metadata: if 'filesize' not in metadata: - metadata['filesize'] = stat.st_size + if stat is not None: + metadata['filesize'] = stat.st_size return metadata + if stat is None: + raise ValueError('File does not exist') + mime_type, uncertain_result_ = Gio.content_type_guess(filename=path, data=None) return {'uid': path, @@ -457,10 +670,17 @@ def _get_file_metadata_from_json(dir_path, filename, fetch_preview): If the metadata is corrupted we do remove it and the preview as well. """ + + # In case of nested mount-points, (eg. ~/Documents/in1/in2/in3.txt), + # "dir_path = ~/Documents/in1/in2"; while + # "metadata_dir_path = ~/Documents". + from jarabe.journal.journalactivity import get_mount_point + metadata_dir_path = get_mount_point() + metadata = None - metadata_path = os.path.join(dir_path, JOURNAL_METADATA_DIR, + metadata_path = os.path.join(metadata_dir_path, JOURNAL_METADATA_DIR, filename + '.metadata') - preview_path = os.path.join(dir_path, JOURNAL_METADATA_DIR, + preview_path = os.path.join(metadata_dir_path, JOURNAL_METADATA_DIR, filename + '.preview') if not os.path.exists(metadata_path): @@ -529,6 +749,9 @@ def find(query_, page_size): if mount_points[0] == '/': return DatastoreResultSet(query, page_size) + elif is_mount_point_for_locally_mounted_remote_share(mount_points[0]): + ip_address = extract_ip_address_or_dns_name_from_locally_mounted_remote_share_path(mount_points[0]) + return RemoteShareResultSet(ip_address, query) else: return InplaceResultSet(query, page_size, mount_points[0]) @@ -546,8 +769,12 @@ def _get_mount_point(path): def get(object_id): """Returns the metadata for an object """ - if os.path.exists(object_id): - stat = os.stat(object_id) + if (object_id[0] == '/'): + if os.path.exists(object_id): + stat = os.stat(object_id) + else: + stat = None + metadata = _get_file_metadata(object_id, stat) metadata['mountpoint'] = _get_mount_point(object_id) else: @@ -620,7 +847,21 @@ def delete(object_id): def copy(metadata, mount_point): """Copies an object to another mount point """ + # In all cases (except one), "copy" means the actual duplication of + # the content. + # Only in case of remote downloading, the content is first copied + # to "/tmp" folder. In those cases, copying would refer to a mere + # renaming. + transfer_ownership = False + + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + if is_mount_point_for_locally_mounted_remote_share(current_mount_point): + transfer_ownership = True + metadata = get(metadata['uid']) + if mount_point == '/' and metadata['icon-color'] == '#000000,#ffffff': client = GConf.Client.get_default() metadata['icon-color'] = client.get_string('/desktop/sugar/user/color') @@ -631,7 +872,7 @@ def copy(metadata, mount_point): metadata['mountpoint'] = mount_point del metadata['uid'] - return write(metadata, file_path, transfer_ownership=False) + return write(metadata, file_path, transfer_ownership=transfer_ownership) def write(metadata, file_path='', update_mtime=True, transfer_ownership=True): @@ -653,22 +894,113 @@ def write(metadata, file_path='', update_mtime=True, transfer_ownership=True): object_id = _get_datastore().create(dbus.Dictionary(metadata), file_path, transfer_ownership) + elif metadata.get('mountpoint', '/') == (WEBDAV_MOUNT_POINT + SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME): + filename = metadata['title'] + + ip_address_or_dns_name = SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME + webdavmanager.get_remote_webdav_share_metadata(ip_address_or_dns_name) + + data_webdav_manager = \ + webdavmanager.get_data_webdav_manager(ip_address_or_dns_name) + metadata_webdav_manager = \ + webdavmanager.get_metadata_webdav_manager(ip_address_or_dns_name) + + + # If we get a resource by this name, there is already an entry + # on the server with this name; we do not want to do any + # overwrites. + data_resource = webdavmanager.get_resource_by_resource_key(data_webdav_manager, + '/webdav/' + filename) + metadata_resource = webdavmanager.get_resource_by_resource_key(metadata_webdav_manager, + '/webdav/.Sugar-Metadata/' + filename + '.metadata') + if (data_resource is not None) or (metadata_resource is not None): + raise Exception(_('Entry already present on the server with ' + 'this name. Try again after renaming.')) + + # No entry for this name present. + # So, first write the metadata- and preview-file to temporary + # locations. + metadata_file_path, preview_file_path = \ + _write_metadata_and_preview_files_and_return_file_paths(metadata, + filename) + + # Finally, + # Upload the data file. + webdavmanager.add_resource_by_resource_key(data_webdav_manager, + filename, + file_path) + + # Upload the preview file. + if preview_file_path is not None: + webdavmanager.add_resource_by_resource_key(metadata_webdav_manager, + filename + '.preview', + preview_file_path) + + # Upload the metadata file. + # + # Note that this needs to be the last step. If there was any + # error uploading the data- or the preview-file, control would + # not reach here. + # + # In other words, the control reaches here only if the data- + # and the preview- files have been uploaded. Finally, IF this + # file is successfully uploaded, we have the guarantee that all + # files for a particular journal entry are in place. + webdavmanager.add_resource_by_resource_key(metadata_webdav_manager, + filename + '.metadata', + metadata_file_path) + + + object_id = 'doesn\'t matter' + else: - object_id = _write_entry_on_external_device(metadata, file_path) + object_id = _write_entry_on_external_device(metadata, + file_path, + transfer_ownership) return object_id -def _rename_entry_on_external_device(file_path, destination_path, - metadata_dir_path): +def make_file_fully_permissible(file_path): + fd = os.open(file_path, os.O_RDONLY) + os.fchmod(fd, stat.S_IRWXU | stat.S_IRWXG |stat.S_IRWXO) + os.close(fd) + + +def _rename_entry_on_external_device(file_path, destination_path): """Rename an entry with the associated metadata on an external device.""" old_file_path = file_path if old_file_path != destination_path: - os.rename(file_path, destination_path) + # Strangely, "os.rename" works fine on sugar-jhbuild, but fails + # on XOs, wih the OSError 13 ("invalid cross-device link"). So, + # using the system call "mv". + os.system('mv "%s" "%s"' % (file_path, destination_path)) + make_file_fully_permissible(destination_path) + + + # In renaming, we want to delete the metadata-, and preview- + # files of the current mount-point, and not the destination + # mount-point. + # But we also need to ensure that the directory of + # 'old_file_path' and 'destination_path' are not same. + if os.path.dirname(old_file_path) == os.path.dirname(destination_path): + return + + from jarabe.journal.journalactivity import get_mount_point + + # Also, as a special case, the metadata- and preview-files of + # the remote-shares must never be deleted. For them, only the + # data-file needs to be moved. + if is_mount_point_for_locally_mounted_remote_share(get_mount_point()): + return + + + source_metadata_dir_path = get_mount_point() + '/.Sugar-Metadata' + old_fname = os.path.basename(file_path) - old_files = [os.path.join(metadata_dir_path, + old_files = [os.path.join(source_metadata_dir_path, old_fname + '.metadata'), - os.path.join(metadata_dir_path, + os.path.join(source_metadata_dir_path, old_fname + '.preview')] for ofile in old_files: if os.path.exists(ofile): @@ -679,41 +1011,32 @@ def _rename_entry_on_external_device(file_path, destination_path, 'for file=%s', ofile, old_fname) -def _write_entry_on_external_device(metadata, file_path): - """Create and update an entry copied from the - DS to an external storage device. - - Besides copying the associated file a file for the preview - and one for the metadata are stored in the hidden directory - .Sugar-Metadata. - - This function handles renames of an entry on the - external device and avoids name collisions. Renames are - handled failsafe. - - """ - if 'uid' in metadata and os.path.exists(metadata['uid']): - file_path = metadata['uid'] +def _write_metadata_and_preview_files_and_return_file_paths(metadata, + file_name): + metadata_copy = metadata.copy() + metadata_copy.pop('mountpoint', None) + metadata_copy.pop('uid', None) - if not file_path or not os.path.exists(file_path): - raise ValueError('Entries without a file cannot be copied to ' - 'removable devices') - if not metadata.get('title'): - metadata['title'] = _('Untitled') - file_name = get_file_name(metadata['title'], metadata['mime_type']) + # For copying to School-Server, we need to retain this property. + # Else wise, I have no idea why this property is being removed !! + if (is_mount_point_for_locally_mounted_remote_share(metadata.get('mountpoint', '/')) == False) and \ + (metadata.get('mountpoint', '/') != LOCAL_SHARES_MOUNT_POINT): + metadata_copy.pop('filesize', None) - destination_path = os.path.join(metadata['mountpoint'], file_name) - if destination_path != file_path: - file_name = get_unique_file_name(metadata['mountpoint'], file_name) - destination_path = os.path.join(metadata['mountpoint'], file_name) - clean_name, extension_ = os.path.splitext(file_name) - metadata['title'] = clean_name + # For journal case, there is the special treatment. + if metadata.get('mountpoint', '/') == '/': + if metadata.get('uid', ''): + object_id = _get_datastore().update(metadata['uid'], + dbus.Dictionary(metadata), + '', + False) + else: + object_id = _get_datastore().create(dbus.Dictionary(metadata), + '', + False) + return - metadata_copy = metadata.copy() - metadata_copy.pop('mountpoint', None) - metadata_copy.pop('uid', None) - metadata_copy.pop('filesize', None) metadata_dir_path = os.path.join(metadata['mountpoint'], JOURNAL_METADATA_DIR) @@ -742,11 +1065,64 @@ def _write_entry_on_external_device(metadata, file_path): os.close(fh) os.rename(fn, os.path.join(metadata_dir_path, preview_fname)) - if not os.path.dirname(destination_path) == os.path.dirname(file_path): - shutil.copy(file_path, destination_path) + metadata_destination_path = os.path.join(metadata_dir_path, file_name + '.metadata') + make_file_fully_permissible(metadata_destination_path) + if preview: + preview_destination_path = os.path.join(metadata_dir_path, preview_fname) + make_file_fully_permissible(preview_destination_path) + else: + preview_destination_path = None + + return (metadata_destination_path, preview_destination_path) + + +def update_only_metadata_and_preview_files_and_return_file_paths(metadata): + file_name = get_file_name(metadata['title'], metadata['mime_type']) + _write_metadata_and_preview_files_and_return_file_paths(metadata, + file_name) + + +def _write_entry_on_external_device(metadata, file_path, + transfer_ownership): + """Create and update an entry copied from the + DS to an external storage device. + + Besides copying the associated file a file for the preview + and one for the metadata are stored in the hidden directory + .Sugar-Metadata. + + This function handles renames of an entry on the + external device and avoids name collisions. Renames are + handled failsafe. + + """ + if 'uid' in metadata and os.path.exists(metadata['uid']): + file_path = metadata['uid'] + + if not file_path or not os.path.exists(file_path): + raise ValueError('Entries without a file cannot be copied to ' + 'removable devices') + + if not metadata.get('title'): + metadata['title'] = _('Untitled') + file_name = get_file_name(metadata['title'], metadata['mime_type']) + + destination_path = os.path.join(metadata['mountpoint'], file_name) + if destination_path != file_path: + file_name = get_unique_file_name(metadata['mountpoint'], file_name) + destination_path = os.path.join(metadata['mountpoint'], file_name) + clean_name, extension_ = os.path.splitext(file_name) + metadata['title'] = clean_name + + _write_metadata_and_preview_files_and_return_file_paths(metadata, + file_name) + + if (os.path.dirname(destination_path) == os.path.dirname(file_path)) or \ + (transfer_ownership == True): + _rename_entry_on_external_device(file_path, destination_path) else: - _rename_entry_on_external_device(file_path, destination_path, - metadata_dir_path) + shutil.copy(file_path, destination_path) + make_file_fully_permissible(destination_path) object_id = destination_path created.send(None, object_id=object_id) @@ -796,7 +1172,17 @@ def is_editable(metadata): if metadata.get('mountpoint', '/') == '/': return True else: - return os.access(metadata['mountpoint'], os.W_OK) + # sl#3605: Instead of relying on mountpoint property being + # present in the metadata, use journalactivity api. + # This would work seamlessly, as "Details View' is + # called, upon an entry in the context of a singular + # mount-point. + from jarabe.journal.journalactivity import get_mount_point + mount_point = get_mount_point() + + if is_mount_point_for_locally_mounted_remote_share(mount_point): + return False + return os.access(mount_point, os.W_OK) def get_documents_path(): diff --git a/src/jarabe/journal/objectchooser.py b/src/jarabe/journal/objectchooser.py index d860b0d..45e72af 100644 --- a/src/jarabe/journal/objectchooser.py +++ b/src/jarabe/journal/objectchooser.py @@ -16,15 +16,20 @@ from gettext import gettext as _ import logging +import os from gi.repository import GObject from gi.repository import Gtk from gi.repository import Gdk from gi.repository import Wnck +from sugar3 import env + from sugar3.graphics import style from sugar3.graphics.toolbutton import ToolButton +from sugar3.datastore import datastore + from jarabe.journal.listview import BaseListView from jarabe.journal.listmodel import ListModel from jarabe.journal.journaltoolbox import MainToolbox @@ -47,6 +52,7 @@ class ObjectChooser(Gtk.Window): self.set_border_width(style.LINE_WIDTH) self._selected_object_id = None + self._callback = None self.add_events(Gdk.EventMask.VISIBILITY_NOTIFY_MASK) self.connect('visibility-notify-event', @@ -111,6 +117,15 @@ class ObjectChooser(Gtk.Window): self._selected_object_id = uid self.emit('response', Gtk.ResponseType.ACCEPT) + if self._callback is not None: + self._callback(self._selected_object_id) + + def get_selected_object(self): + if self._selected_object_id is None: + return None + else: + return datastore.get(self._selected_object_id) + def __delete_event_cb(self, chooser, event): self.emit('response', Gtk.ResponseType.DELETE_EVENT) @@ -121,6 +136,8 @@ class ObjectChooser(Gtk.Window): def __close_button_clicked_cb(self, button): self.emit('response', Gtk.ResponseType.DELETE_EVENT) + if self._callback is not None: + self._callback(self._selected_object_id) def get_selected_object_id(self): return self._selected_object_id @@ -140,6 +157,9 @@ class ObjectChooser(Gtk.Window): def __clear_clicked_cb(self, list_view): self._toolbar.clear_query() + def _set_callback(self, callback): + self._callback = callback + class TitleBox(VolumesToolbar): __gtype_name__ = 'TitleBox' diff --git a/src/jarabe/journal/palettes.py b/src/jarabe/journal/palettes.py index 43f9905..f770d55 100644 --- a/src/jarabe/journal/palettes.py +++ b/src/jarabe/journal/palettes.py @@ -15,6 +15,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA from gettext import gettext as _ +from gettext import ngettext import logging import os @@ -38,6 +39,61 @@ from jarabe.model import mimeregistry from jarabe.journal import misc from jarabe.journal import model from jarabe.journal import journalwindow +from jarabe.journal import webdavmanager +from jarabe.journal.journalwindow import freeze_ui, \ + unfreeze_ui, \ + show_normal_cursor, \ + show_waiting_cursor + +from webdav.Connection import WebdavError + + +friends_model = friends.get_model() + +_copy_menu_helper = None +_current_action_item = None + +USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE = _('Cannot perform request. Connection failed.') + + +class PassphraseDialog(Gtk.Dialog): + def __init__(self, callback, metadata): + Gtk.Dialog.__init__(self, flags=Gtk.DialogFlags.MODAL) + self._callback = callback + self._metadata = metadata + self.set_title(_('Passphrase required')) + + # TRANS: Please do not translate the '%s'. + label_text = _('Please enter the passphrase for "%s"' % metadata['title']) + label = Gtk.Label(label_text) + self.vbox.pack_start(label, True, True, 0) + + self.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK) + self.set_default_response(Gtk.ResponseType.OK) + self.add_key_entry() + + self.connect('response', self._key_dialog_response_cb) + self.show_all() + + def add_key_entry(self): + self._entry = Gtk.Entry() + self._entry.connect('activate', self._entry_activate_cb) + self.vbox.pack_start(self._entry, True, True, 0) + self.vbox.set_spacing(6) + self.vbox.show_all() + + self._entry.grab_focus() + + def _entry_activate_cb(self, entry): + self.response(Gtk.ResponseType.OK) + + def get_response_object(self): + return self._response + + def _key_dialog_response_cb(self, widget, response_id): + self.hide() + GObject.idle_add(self._callback, self._metadata, + self._entry.get_text()) class ObjectPalette(Palette): @@ -68,6 +124,9 @@ class ObjectPalette(Palette): Palette.__init__(self, primary_text=title, icon=activity_icon) + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + if misc.get_activities(metadata) or misc.is_bundle(metadata): if metadata.get('activity_id', ''): resume_label = _('Resume') @@ -77,10 +136,14 @@ class ObjectPalette(Palette): resume_with_label = _('Start with') menu_item = MenuItem(resume_label, 'activity-start') menu_item.connect('activate', self.__start_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() menu_item = MenuItem(resume_with_label, 'activity-start') + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() start_with_menu = StartWithMenu(self._metadata) @@ -99,6 +162,15 @@ class ObjectPalette(Palette): self.menu.append(menu_item) menu_item.show() copy_menu = CopyMenu(metadata) + copy_menu_helper = get_copy_menu_helper() + + metadata_list = [] + metadata_list.append(metadata) + copy_menu_helper.insert_copy_to_menu_items(copy_menu, + metadata_list, + False, + False, + False) copy_menu.connect('volume-error', self.__volume_error_cb) menu_item.set_submenu(copy_menu) @@ -112,6 +184,8 @@ class ObjectPalette(Palette): menu_item.show() menu_item = MenuItem(_('Send to'), 'document-send') + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() @@ -127,6 +201,8 @@ class ObjectPalette(Palette): menu_item = MenuItem(_('Erase'), 'list-remove') menu_item.connect('activate', self.__erase_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() @@ -197,123 +273,798 @@ class CopyMenu(Gtk.Menu): __gsignals__ = { 'volume-error': (GObject.SignalFlags.RUN_FIRST, None, - ([str, str])), + ([str, str])), } def __init__(self, metadata): Gtk.Menu.__init__(self) - self._metadata = metadata - clipboard_menu = ClipboardMenu(self._metadata) - clipboard_menu.set_image(Icon(icon_name='toolbar-edit', - icon_size=Gtk.IconSize.MENU)) - clipboard_menu.connect('volume-error', self.__volume_error_cb) - self.append(clipboard_menu) - clipboard_menu.show() +class ActionItem(GObject.GObject): + """ + This class implements the course of actions that happens when clicking + upon an Action-Item (eg. Batch-Copy-Toolbar-button; + Actual-Batch-Copy-To-Journal-button; + Actual-Batch-Copy-To-Documents-button; + Actual-Batch-Copy-To-Mounted-Drive-button; + Actual-Batch-Copy-To-Clipboard-button; + Single-Copy-To-Journal-button; + Single-Copy-To-Documents-button; + Single-Copy-To-Mounted-Drive-button; + Single-Copy-To-Clipboard-button; + Batch-Erase-Button; + Select-None-Toolbar-button; + Select-All-Toolbar-button + """ + __gtype_name__ = 'JournalActionItem' + + def __init__(self, label, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode, + auto_deselect_source_entries, + need_to_popup_options, + operate_on_deselected_entries, + show_not_completed_ops_info): + GObject.GObject.__init__(self) + + self._label = label + + # Make a copy. + self._immutable_metadata_list = [] + for metadata in metadata_list: + self._immutable_metadata_list.append(metadata) + + self._metadata_list = metadata_list + self._show_progress_info_alert = show_progress_info_alert + self._batch_mode = batch_mode + self._auto_deselect_source_entries = \ + auto_deselect_source_entries + self._need_to_popup_options = \ + need_to_popup_options + self._operate_on_deselected_entries = \ + operate_on_deselected_entries + self._show_not_completed_ops_info = \ + show_not_completed_ops_info + + actionable_signal = self._get_actionable_signal() + + if need_to_popup_options: + self.connect(actionable_signal, self._pre_fill_and_pop_up_options) + else: + if show_editing_alert: + self.connect(actionable_signal, self._show_editing_alert) + else: + self.connect(actionable_signal, + self._pre_operate_per_action, + Gtk.ResponseType.OK) + + def _get_actionable_signal(self): + """ + Some widgets like 'buttons' have 'clicked' as actionable signal; + some like 'menuitems' have 'activate' as actionable signal. + """ + + raise NotImplementedError + + def _pre_fill_and_pop_up_options(self, widget_clicked): + self._set_current_action_item_widget() + self._fill_and_pop_up_options(widget_clicked) + + def _fill_and_pop_up_options(self, widget_clicked): + """ + Eg. Batch-Copy-Toolbar-button does not do anything by itself + useful; but rather pops-up the actual 'copy-to' options. + """ + + raise NotImplementedError + + def _show_editing_alert(self, widget_clicked): + """ + Upon clicking the actual operation button (eg. + Batch-Erase-Button and Batch-Copy-To-Clipboard button; BUT NOT + Batch-Copy-Toolbar-button, since it does not do anything + actually useful, but only pops-up the actual 'copy-to' options. + """ + + freeze_ui() + GObject.idle_add(self.__show_editing_alert_after_freezing_ui, + widget_clicked) + + def __show_editing_alert_after_freezing_ui(self, widget_clicked): + self._set_current_action_item_widget() + + alert_parameters = self._get_editing_alert_parameters() + title = alert_parameters[0] + message = alert_parameters[1] + operation = alert_parameters[2] + + from jarabe.journal.journalactivity import get_journal + get_journal().update_confirmation_alert(title, message, + self._pre_operate_per_action, + None) + + def _get_editing_alert_parameters(self): + """ + Get the alert parameters for widgets that can show editing + alert. + """ + + self._metadata_list = self._get_metadata_list() + entries_len = len(self._metadata_list) + + title = self._get_editing_alert_title() + message = self._get_editing_alert_message(entries_len) + operation = self._get_editing_alert_operation() + + return (title, message, operation) + + def _get_list_model_len(self): + """ + Get the total length of the model under view. + """ + + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + return len(journal.get_list_view().get_model()) + + def _get_metadata_list(self): + """ + For batch-mode, get the metadata list, according to button-type. + For eg, Select-All-Toolbar-button operates on non-selected entries; + while othere operate on selected-entries. + + For single-mode, simply copy from the + "immutable_metadata_list". + """ + + if self._batch_mode: + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + if self._operate_on_deselected_entries: + metadata_list = journal.get_metadata_list(False) + else: + metadata_list = journal.get_metadata_list(True) - if self._metadata['mountpoint'] != '/': - client = GConf.Client.get_default() - color = XoColor(client.get_string('/desktop/sugar/user/color')) - journal_menu = VolumeMenu(self._metadata, _('Journal'), '/') - journal_menu.set_image(Icon(icon_name='activity-journal', - xo_color=color, - icon_size=Gtk.IconSize.MENU)) - journal_menu.connect('volume-error', self.__volume_error_cb) - self.append(journal_menu) - journal_menu.show() + # Make a backup copy, of this metadata_list. + self._immutable_metadata_list = [] + for metadata in metadata_list: + self._immutable_metadata_list.append(metadata) - volume_monitor = Gio.VolumeMonitor.get() - icon_theme = Gtk.IconTheme.get_default() - for mount in volume_monitor.get_mounts(): - if self._metadata['mountpoint'] == mount.get_root().get_path(): - continue - volume_menu = VolumeMenu(self._metadata, mount.get_name(), - mount.get_root().get_path()) - for name in mount.get_icon().props.names: - if icon_theme.has_icon(name): - volume_menu.set_image(Icon(icon_name=name, - icon_size=Gtk.IconSize.MENU)) - break - volume_menu.connect('volume-error', self.__volume_error_cb) - self.append(volume_menu) - volume_menu.show() + return metadata_list + else: + metadata_list = [] + for metadata in self._immutable_metadata_list: + metadata_list.append(metadata) + return metadata_list + + def _get_editing_alert_title(self): + raise NotImplementedError + + def _get_editing_alert_message(self, entries_len): + raise NotImplementedError + + def _get_editing_alert_operation(self): + raise NotImplementedError + + def _is_metadata_list_empty(self): + return (self._metadata_list is None) or \ + (len(self._metadata_list) == 0) + + def _set_current_action_item_widget(self): + """ + Only set this, if this widget achieves some effective action. + """ + if not self._need_to_popup_options: + global _current_action_item + _current_action_item = self + + def _pre_operate_per_action(self, obj, response_id): + """ + This is the stage, just before the FIRST metadata gets into its + processing cycle. + """ + freeze_ui() + GObject.idle_add(self._pre_operate_per_action_after_done_ui_freezing, + obj, response_id) + + def _pre_operate_per_action_after_done_ui_freezing(self, obj, + response_id): + self._set_current_action_item_widget() + + self._continue_operation = True + + # If the user chose to cancel the operation from the onset, + # simply proceeed to the last. + if response_id == Gtk.ResponseType.CANCEL: + unfreeze_ui() + + self._cancel_further_batch_operation_items() + self._post_operate_per_action() + return - def __volume_error_cb(self, menu_item, message, severity): - self.emit('volume-error', message, severity) + self._skip_all = False + # Also, get the initial length of the model. + self._model_len = self._get_list_model_len() -class VolumeMenu(MenuItem): - __gtype_name__ = 'JournalVolumeMenu' + # Speed Optimisation: + # =================== + # If the metadata-list is empty, fetch it; + # else we have already fetched it, when we showed the + # "editing-alert". + if len(self._metadata_list) == 0: + self._metadata_list = self._get_metadata_list() - __gsignals__ = { - 'volume-error': (GObject.SignalFlags.RUN_FIRST, None, - ([str, str])), - } + # Set the initial length of metadata-list. + self._metadata_list_initial_len = len(self._metadata_list) - def __init__(self, metadata, label, mount_point): - MenuItem.__init__(self, label) - self._metadata = metadata - self.connect('activate', self.__copy_to_volume_cb, mount_point) + self._metadata_processed = 0 - def __copy_to_volume_cb(self, menu_item, mount_point): - file_path = model.get_file(self._metadata['uid']) + # Next, proceed with the metadata + self._pre_operate_per_metadata_per_action() + + def _pre_operate_per_metadata_per_action(self): + """ + This is the stage, just before EVERY metadata gets into doing + its actual work. + """ + + show_waiting_cursor() + GObject.idle_add(self.__pre_operate_per_metadata_per_action_after_freezing_ui) + + def __pre_operate_per_metadata_per_action_after_freezing_ui(self): + from jarabe.journal.journalactivity import get_journal + + # If there is still some metadata left, proceed with the + # metadata operation. + # Else, proceed to post-operations. + if len(self._metadata_list) > 0: + metadata = self._metadata_list.pop(0) + + # If info-alert needs to be shown, show the alert, and + # arrange for actual operation. + # Else, proceed to actual operation directly. + if self._show_progress_info_alert: + current_len = len(self._metadata_list) + + # TRANS: Do not translate the two %d, and the %s. + info_alert_message = _(' %d of %d : %s') % ( + self._metadata_list_initial_len - current_len, + self._metadata_list_initial_len, metadata['title']) + + get_journal().update_info_alert(self._get_info_alert_title(), + info_alert_message) + + # Call the core-function !! + GObject.idle_add(self._operate_per_metadata_per_action, metadata) + else: + self._post_operate_per_action() + + def _get_info_alert_title(self): + raise NotImplementedError + + def _operate_per_metadata_per_action(self, metadata): + """ + This is just a code-convenient-function, which allows + runtime-overriding. It just delegates to the actual + "self._operate" method, the actual which is determined at + runtime. + """ + + if self._continue_operation is False: + # Jump directly to the post-operation + self._post_operate_per_metadata_per_action(metadata) + else: + # Pass the callback for the post-operation-for-metadata. This + # will ensure that async-operations on the metadata are taken + # care of. + if self._operate(metadata) is False: + return + else: + self._metadata_processed = self._metadata_processed + 1 + + def _operate(self, metadata): + """ + Actual, core, productive stage for EVERY metadata. + """ + raise NotImplementedError + + def _post_operate_per_metadata_per_action(self, metadata, + response_id=None): + """ + This is the stage, just after EVERY metadata has been + processed. + """ + self._hide_info_widget_for_single_mode() + + # Toggle the corresponding checkbox - but only for batch-mode. + if self._batch_mode and self._auto_deselect_source_entries: + from jarabe.journal.journalactivity import get_journal + list_view = get_journal().get_list_view() + + list_view.do_ui_select_change(metadata) + list_view.do_backend_select_change(metadata) + + # Call the next ... + self._pre_operate_per_metadata_per_action() + + def _post_operate_per_action(self): + """ + This is the stage, just after the LAST metadata has been + processed. + """ + + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + journal_toolbar_box = journal.get_toolbar_box() + + if self._batch_mode and (not self._auto_deselect_source_entries): + journal_toolbar_box.display_already_selected_entries_status() + + self._process_switching_mode(None, False) + + unfreeze_ui() + + # Set the "_current_action_item" to None. + global _current_action_item + _current_action_item = None + + def _process_switching_mode(self, metadata, ok_clicked=False): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + # Necessary to do this, when the alert needs to be hidden, + # WITHOUT user-intervention. + journal.hide_alert() + + def _refresh(self): + from jarabe.journal.journalactivity import get_journal + get_journal().get_list_view().refresh() + + def _handle_single_mode_notification(self, message, severity): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal._show_alert(message, severity) + self._hide_info_widget_for_single_mode() + + def _hide_info_widget_for_single_mode(self): + if (not self._batch_mode): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.get_toolbar_box().hide_info_widget() + + def _unhide_info_widget_for_single_mode(self): + if not self._batch_mode: + from jarabe.journal.journalactivity import get_journal + get_journal().update_progress(0) + + def _handle_error_alert(self, error_message, metadata): + """ + This handles any error scenarios. Examples are of entries that + display the message "Entries without a file cannot be copied." + This is kind of controller-functionl the model-function is + "self._set_error_info_alert". + """ + + if self._skip_all: + self._post_operate_per_metadata_per_action(metadata) + else: + self._set_error_info_alert(error_message, metadata) + + def _set_error_info_alert(self, error_message, metadata): + """ + This method displays the error alert. + """ + + current_len = len(self._metadata_list) + + # Only show the alert, if allowed to. + if self._show_not_completed_ops_info: + from jarabe.journal.journalactivity import get_journal + get_journal().update_confirmation_alert(_('Error'), + error_message, + self._process_error_skipping, + metadata) + else: + self._process_error_skipping(metadata, gtk.RESPONSE_OK) + + def _process_error_skipping(self, metadata, response_id): + # This sets up the decision, as to whether continue operations + # with the rest of the metadata. + if response_id == Gtk.ResponseType.CANCEL: + self._cancel_further_batch_operation_items() + + self._post_operate_per_metadata_per_action(metadata) + + def _cancel_further_batch_operation_items(self): + self._continue_operation = False + + # Optimization: + # Clear the metadata-list as well. + # This would prevent the unnecessary traversing of the + # remaining checkboxes-corresponding-to-remaining-metadata (of + # course without doing any effective action). + self._metadata_list = [] + + def _file_path_valid(self, metadata): + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + # Now, for locally mounted remote-shares, download the file. + # Note that, always download the file, to avoid the problems + # of stale-cache. + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + file_path = metadata['uid'] + filename = os.path.basename(file_path) + ip_address_or_dns_name = \ + model.extract_ip_address_or_dns_name_from_locally_mounted_remote_share_path(file_path) + + data_webdav_manager = \ + webdavmanager.get_data_webdav_manager(ip_address_or_dns_name) + metadata_webdav_manager = \ + webdavmanager.get_metadata_webdav_manager(ip_address_or_dns_name) + + # Download the preview file, if it exists. + preview_resource = \ + webdavmanager.get_resource_by_resource_key(metadata_webdav_manager, + '/webdav/.Sugar-Metadata/' + filename + '.preview') + preview_path = os.path.dirname(file_path) + '/.Sugar-Metadata/'+ filename + '.preview' + + if preview_resource is not None: + try: + preview_resource.downloadFile(preview_path, + show_progress=False, + filesize=0) + except (WebdavError, socket.error), e: + error_message = USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + logging.warn(error_message) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, + _('Error')) + return False + + # If we manage to reach here, download the data file. + data_resource = \ + webdavmanager.get_resource_by_resource_key(data_webdav_manager, + '/webdav/'+ filename) + try: + data_resource.downloadFile(metadata['uid'], + show_progress=True, + filesize=int(metadata['filesize'])) + return True + except (WebdavError, socket.error), e: + # Delete the downloaded preview file, if it exists. + if os.path.exists(preview_path): + os.unlink(preview_path) + + error_message = USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + logging.warn(error_message) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, + _('Error')) + return False + + file_path = model.get_file(metadata['uid']) if not file_path or not os.path.exists(file_path): logging.warn('Entries without a file cannot be copied.') - self.emit('volume-error', - _('Entries without a file cannot be copied.'), - _('Warning')) - return + error_message = _('Entries without a file cannot be copied.') + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Warning')) + return False + else: + return True + + def _metadata_copy_valid(self, metadata, mount_point): + self._set_bundle_installation_allowed(False) try: - model.copy(self._metadata, mount_point) - except IOError, e: - logging.exception('Error while copying the entry. %s', e.strerror) - self.emit('volume-error', - _('Error while copying the entry. %s') % e.strerror, - _('Error')) + model.copy(metadata, mount_point) + return True + except Exception, e: + logging.exception(e) + error_message = _('Error while copying the entry. %s') % e + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Error')) + return False + finally: + self._set_bundle_installation_allowed(True) + def _metadata_write_valid(self, metadata): + operation = self._get_info_alert_title() + self._set_bundle_installation_allowed(False) -class ClipboardMenu(MenuItem): - __gtype_name__ = 'JournalClipboardMenu' + try: + model.update_only_metadata_and_preview_files_and_return_file_paths(metadata) + return True + except Exception, e: + logging.exception('Error while writing the metadata. %s', e) + error_message = _('Error occurred while %s : %s.') % \ + (operation, e,) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Error')) + return False + finally: + self._set_bundle_installation_allowed(True) - __gsignals__ = { - 'volume-error': (GObject.SignalFlags.RUN_FIRST, None, - ([str, str])), - } + def _set_bundle_installation_allowed(self, allowed): + """ + This method serves only as a "delegating" method. + This has been done to aid easy configurability. + """ + from jarabe.journal.journalactivity import get_journal + journal = get_journal() - def __init__(self, metadata): - MenuItem.__init__(self, _('Clipboard')) + if self._batch_mode: + journal.set_bundle_installation_allowed(allowed) - self._temp_file_path = None - self._metadata = metadata - self.connect('activate', self.__copy_to_clipboard_cb) + def get_number_of_entries_to_operate_upon(self): + return len(self._immutable_metadata_list) - def __copy_to_clipboard_cb(self, menu_item): - file_path = model.get_file(self._metadata['uid']) - if not file_path or not os.path.exists(file_path): - logging.warn('Entries without a file cannot be copied.') - self.emit('volume-error', - _('Entries without a file cannot be copied.'), - _('Warning')) - return + +class BaseCopyMenuItem(MenuItem, ActionItem): + __gtype_name__ = 'JournalBaseCopyMenuItem' + + __gsignals__ = { + 'volume-error': (GObject.SignalFlags.RUN_FIRST, + None, ([str, str])), + } + + def __init__(self, metadata_list, label, show_editing_alert, + show_progress_info_alert, batch_mode, mount_point): + MenuItem.__init__(self, label) + ActionItem.__init__(self, label, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode, + auto_deselect_source_entries=False, + need_to_popup_options=False, + operate_on_deselected_entries=False, + show_not_completed_ops_info=True) + self._mount_point = mount_point + + def get_mount_point(self): + return self._mount_point + + def _get_actionable_signal(self): + return 'activate' + + def _get_editing_alert_title(self): + return _('Copy') + + def _get_editing_alert_message(self, entries_len): + return ngettext('Do you want to copy %d entry to %s?', + 'Do you want to copy %d entries to %s?', + entries_len) % (entries_len, self._label) + + def _get_editing_alert_operation(self): + return _('Copy') + + def _get_info_alert_title(self): + return _('Copying') + + def _operate(self, metadata): + from jarabe.journal.journalactivity import get_mount_point + if(model.is_mount_point_for_locally_mounted_remote_share(get_mount_point())) \ + and (model.is_mount_point_for_school_server(get_mount_point()) == True): + PassphraseDialog(self._proceed_after_receiving_passphrase, metadata) + else: + self._proceed_with_copy(metadata) + + def _proceed_after_receiving_passphrase(self, metadata, passphrase): + if metadata['passphrase'] != passphrase: + error_message = _('Passphrase does not match.') + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Error')) + return False + else: + self._unhide_info_widget_for_single_mode() + GObject.idle_add(self._proceed_with_copy, metadata) + + def _proceed_with_copy(self, metadata): + return NotImplementedError + + def _post_successful_copy(self, metadata, response_id=None): + from jarabe.journal.journalactivity import get_journal, \ + get_mount_point + + if model.is_mount_point_for_locally_mounted_remote_share(get_mount_point()): + successful_downloading_message = None + + if model.is_mount_point_for_school_server(get_mount_point()) == True: + # TRANS: Do not translate the %s. + successful_downloading_message = \ + _('Your file "%s" was correctly downloaded from the School Server.' % metadata['title']) + else: + # TRANS: Do not translate the %s. + successful_downloading_message = \ + _('Your file "%s" was correctly downloaded from the Peer.' % metadata['title']) + + from jarabe.journal.journalactivity import get_journal + get_journal().update_error_alert(self._get_editing_alert_title(), + successful_downloading_message, + self._post_operate_per_metadata_per_action, + metadata) + else: + self._post_operate_per_metadata_per_action(metadata) + + +class VolumeMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, label, mount_point, + show_editing_alert, show_progress_info_alert, + batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, label, + show_editing_alert, + show_progress_info_alert, batch_mode, + mount_point) + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, self._mount_point): + return False + + # This is sync-operation. Thus, call the callback. + self._post_successful_copy(metadata) + + +class ClipboardMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Clipboard'), + show_editing_alert, + show_progress_info_alert, + batch_mode, None) + self._temp_file_path_list = [] + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False clipboard = Gtk.Clipboard() clipboard.set_with_data([('text/uri-list', 0, 0)], self.__clipboard_get_func_cb, - self.__clipboard_clear_func_cb) + self.__clipboard_clear_func_cb, + metadata) - def __clipboard_get_func_cb(self, clipboard, selection_data, info, data): + def __clipboard_get_func_cb(self, clipboard, selection_data, info, + metadata): # Get hold of a reference so the temp file doesn't get deleted - self._temp_file_path = model.get_file(self._metadata['uid']) + self._temp_file_path = model.get_file(metadata['uid']) logging.debug('__clipboard_get_func_cb %r', self._temp_file_path) selection_data.set_uris(['file://' + self._temp_file_path]) - def __clipboard_clear_func_cb(self, clipboard, data): + def __clipboard_clear_func_cb(self, clipboard, metadata): # Release and delete the temp file self._temp_file_path = None + # This is async-operation; and this is the ending point. + self._post_successful_copy(metadata) + + +class DocumentsMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Documents'), + show_editing_alert, + show_progress_info_alert, + batch_mode, + model.get_documents_path()) + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, + model.get_documents_path()): + return False + + # This is sync-operation. Call the post-operation now. + self._post_successful_copy(metadata) + + +class LocalSharesMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Local Shares'), + show_editing_alert, + show_progress_info_alert, + batch_mode, + model.LOCAL_SHARES_MOUNT_POINT) + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False + + # Attach the filesize. + file_path = model.get_file(metadata['uid']) + metadata['filesize'] = os.stat(file_path).st_size + + # Attach the current mount-point. + from jarabe.journal.journalactivity import get_mount_point + metadata['mountpoint'] = get_mount_point() + + if not self._metadata_write_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, + model.LOCAL_SHARES_MOUNT_POINT): + return False + + # This is sync-operation. Call the post-operation now. + self._post_successful_copy(metadata) + + +class SchoolServerMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('School Server'), + show_editing_alert, + show_progress_info_alert, + batch_mode, + model.WEBDAV_MOUNT_POINT + model.SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME) + + def _operate(self, metadata): + if not self._file_path_valid(metadata): + return False + + # If the entry is copyable, proceed with asking the + # upload-passphrase. + PassphraseDialog(self._proceed_after_receiving_passphrase, metadata) + + def _proceed_after_receiving_passphrase(self, metadata, passphrase): + self._unhide_info_widget_for_single_mode() + GObject.idle_add(self._proceed_with_uploading, metadata, + passphrase) + + def _proceed_with_uploading(self, metadata, passphrase): + # + # Attach the passphrase. + metadata['passphrase'] = passphrase + + # Attach the filesize. + file_path = model.get_file(metadata['uid']) + metadata['filesize'] = os.stat(file_path).st_size + + # Attach the current mount-point. + from jarabe.journal.journalactivity import get_mount_point, \ + get_journal + metadata['mountpoint'] = get_mount_point() + + # Attach the info of the uploader. + from jarabe.model.buddy import get_owner_instance + metadata['uploader-nick'] = get_owner_instance().props.nick + metadata['uploader-serial'] = misc.get_xo_serial() + + if not self._metadata_write_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, + model.WEBDAV_MOUNT_POINT + model.SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME): + return False + + # TRANS: Do not translate the %s. + successful_uploading_message = \ + _('Your file "%s" was correctly uploaded to the School Server.' % metadata['title']) + get_journal().update_error_alert(self._get_editing_alert_title(), + successful_uploading_message, + self._post_successful_copy, + metadata) + class FriendsMenu(Gtk.Menu): __gtype_name__ = 'JournalFriendsMenu' @@ -401,3 +1152,116 @@ class BuddyPalette(Palette): icon=buddy_icon) # TODO: Support actions on buddies, like make friend, invite, etc. + + +class CopyMenuHelper(Gtk.Menu): + __gtype_name__ = 'JournalCopyMenuHelper' + + __gsignals__ = { + 'volume-error': (GObject.SignalFlags.RUN_FIRST, + None, ([str, str])), + } + + def insert_copy_to_menu_items(self, menu, metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode): + self._metadata_list = metadata_list + + clipboard_menu = ClipboardMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + clipboard_menu.set_image(Icon(icon_name='toolbar-edit', + icon_size=Gtk.IconSize.MENU)) + clipboard_menu.connect('volume-error', self.__volume_error_cb) + menu.append(clipboard_menu) + clipboard_menu.show() + + from jarabe.journal.journalactivity import get_mount_point + + if get_mount_point() != model.get_documents_path(): + documents_menu = DocumentsMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + documents_menu.set_image(Icon(icon_name='user-documents', + icon_size=Gtk.IconSize.MENU)) + documents_menu.connect('volume-error', self.__volume_error_cb) + menu.append(documents_menu) + documents_menu.show() + + if (model.is_school_server_present()) and \ + (not model.is_mount_point_for_locally_mounted_remote_share(get_mount_point())): + documents_menu = SchoolServerMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + documents_menu.set_image(Icon(icon_name='school-server', + icon_size=Gtk.IconSize.MENU)) + documents_menu.connect('volume-error', self.__volume_error_cb) + menu.append(documents_menu) + documents_menu.show() + + if (model.is_peer_to_peer_sharing_available()) and \ + (get_mount_point() != model.LOCAL_SHARES_MOUNT_POINT): + local_shares_menu = LocalSharesMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + local_shares_menu.set_image(Icon(icon_name='emblem-neighborhood-shared', + icon_size=Gtk.IconSize.MENU)) + local_shares_menu.connect('volume-error', self.__volume_error_cb) + menu.append(local_shares_menu) + local_shares_menu.show() + + if get_mount_point() != '/': + client = GConf.Client.get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + journal_menu = VolumeMenu(metadata_list, _('Journal'), '/', + show_editing_alert, + show_progress_info_alert, + batch_mode) + journal_menu.set_image(Icon(icon_name='activity-journal', + xo_color=color, + icon_size=Gtk.IconSize.MENU)) + journal_menu.connect('volume-error', self.__volume_error_cb) + menu.append(journal_menu) + journal_menu.show() + + volume_monitor = Gio.VolumeMonitor.get() + icon_theme = Gtk.IconTheme.get_default() + for mount in volume_monitor.get_mounts(): + if get_mount_point() == mount.get_root().get_path(): + continue + + volume_menu = VolumeMenu(metadata_list, mount.get_name(), + mount.get_root().get_path(), + show_editing_alert, + show_progress_info_alert, + batch_mode) + for name in mount.get_icon().props.names: + if icon_theme.has_icon(name): + volume_menu.set_image(Icon(icon_name=name, + icon_size=Gtk.IconSize.MENU)) + break + + volume_menu.connect('volume-error', self.__volume_error_cb) + menu.insert(volume_menu, -1) + volume_menu.show() + + def __volume_error_cb(self, menu_item, message, severity): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + journal._volume_error_cb(menu_item, message, severity) + + +def get_copy_menu_helper(): + global _copy_menu_helper + if _copy_menu_helper is None: + _copy_menu_helper = CopyMenuHelper() + return _copy_menu_helper + + +def get_current_action_item(): + return _current_action_item diff --git a/src/jarabe/journal/processdialog.py b/src/jarabe/journal/processdialog.py new file mode 100644 index 0000000..d738303 --- /dev/null +++ b/src/jarabe/journal/processdialog.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python +# Copyright (C) 2010, Plan Ceibal <comunidad@plan.ceibal.edu.uy> +# Copyright (C) 2010, Paraguay Educa <tecnologia@paraguayeduca.org> +# +# 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import Gdk +from gi.repository import GConf + +import logging + +from gettext import gettext as _ +from sugar3.graphics import style +from sugar3.graphics.icon import Icon +from sugar3.graphics.xocolor import XoColor + +from jarabe.journal import misc +from jarabe.model import shell +from jarabe.model import processmanagement +from jarabe.model.session import get_session_manager + +class ProcessDialog(Gtk.Window): + + __gtype_name__ = 'SugarProcessDialog' + + def __init__(self, process_script='', process_params=[], restart_after=True): + + #FIXME: Workaround limitations of Sugar core modal handling + shell_model = shell.get_model() + shell_model.set_zoom_level(shell_model.ZOOM_HOME) + + Gtk.Window.__init__(self) + + self._process_script = processmanagement.find_and_absolutize(process_script) + self._process_params = process_params + self._restart_after = restart_after + self._start_message = _('Running') + self._failed_message = _('Failed') + self._finished_message = _('Finished') + self._prerequisite_message = ('Prerequisites were not met') + + self.set_border_width(style.LINE_WIDTH) + width = Gdk.Screen.width() + height = Gdk.Screen.height() + self.set_size_request(width, height) + self.set_position(Gtk.WindowPosition.CENTER_ALWAYS) + self.set_decorated(False) + self.set_resizable(False) + self.set_modal(True) + + self._colored_box = Gtk.EventBox() + self._colored_box.modify_bg(Gtk.StateType.NORMAL, Gdk.Color.parse("white")[1]) + self._colored_box.show() + + self._vbox = Gtk.VBox() + self._vbox.set_spacing(style.DEFAULT_SPACING) + self._vbox.set_border_width(style.GRID_CELL_SIZE) + + self._colored_box.add(self._vbox) + self.add(self._colored_box) + + self._setup_information() + self._setup_progress_bar() + self._setup_options() + + self._vbox.show() + + self.connect("realize", self.__realize_cb) + + self._process_management = processmanagement.ProcessManagement() + self._process_management.connect('process-management-running', self._set_status_updated) + self._process_management.connect('process-management-started', self._set_status_started) + self._process_management.connect('process-management-finished', self._set_status_finished) + self._process_management.connect('process-management-failed', self._set_status_failed) + + def _setup_information(self): + client = GConf.Client.get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + + self._icon = Icon(icon_name='activity-journal', pixel_size=style.XLARGE_ICON_SIZE, xo_color=color) + self._icon.show() + + self._vbox.pack_start(self._icon, False, False, 0) + + self._title = Gtk.Label() + self._title.modify_fg(Gtk.StateType.NORMAL, style.COLOR_BLACK.get_gdk_color()) + self._title.set_use_markup(True) + self._title.set_justify(Gtk.Justification.CENTER) + self._title.show() + + self._vbox.pack_start(self._title, False, False, 0) + + self._message = Gtk.Label() + self._message.modify_fg(Gtk.StateType.NORMAL, style.COLOR_BLACK.get_gdk_color()) + self._message.set_use_markup(True) + self._message.set_line_wrap(True) + self._message.set_justify(Gtk.Justification.CENTER) + self._message.show() + + self._vbox.pack_start(self._message, True, True, 0) + + def _setup_options(self): + hbox = Gtk.HBox(True, 3) + hbox.show() + + icon = Icon(icon_name='dialog-ok') + + self._start_button = Gtk.Button() + self._start_button.set_image(icon) + self._start_button.set_label(_('Start')) + self._start_button.connect('clicked', self.__start_cb) + self._start_button.show() + + icon = Icon(icon_name='dialog-cancel') + + self._close_button = Gtk.Button() + self._close_button.set_image(icon) + self._close_button.set_label(_('Close')) + self._close_button.connect('clicked', self.__close_cb) + self._close_button.show() + + icon = Icon(icon_name='system-restart') + + self._restart_button = Gtk.Button() + self._restart_button.set_image(icon) + self._restart_button.set_label(_('Restart')) + self._restart_button.connect('clicked', self.__restart_cb) + self._restart_button.hide() + + hbox.add(self._start_button) + hbox.add(self._close_button) + hbox.add(self._restart_button) + + halign = Gtk.Alignment(xalign=1, yalign=0, xscale=0, yscale=0) + halign.show() + halign.add(hbox) + + self._vbox.pack_start(halign, False, False, 3) + + def _setup_progress_bar(self): + alignment = Gtk.Alignment(xalign=0.5, yalign=0.5, xscale=0.5) + alignment.show() + + self._progress_bar = Gtk.ProgressBar() + self._progress_bar.hide() + + alignment.add(self._progress_bar) + self._vbox.pack_start(alignment, False, False, 0) + + def __realize_cb(self, widget): + self.get_window().set_accept_focus(True) + + def __close_cb(self, button): + self.destroy() + + def __start_cb(self, button): + if self._check_prerequisites(): + self._process_management.do_process([self._process_script] + self._process_params) + else: + self._set_status_failed(self, error_message=self._prerequisite_message) + + def __restart_cb(self, button): + session_manager = get_session_manager() + session_manager.logout() + + def _check_prerequisites(self): + return True + + def _set_status_started(self, model, data=None): + self._message.set_markup(self._start_message) + + self._start_button.hide() + self._close_button.hide() + + self._progress_bar.set_fraction(0.05) + self._progress_bar_handler = GObject.timeout_add(1000, self.__progress_bar_handler_cb) + self._progress_bar.show() + + def __progress_bar_handler_cb(self): + self._progress_bar.pulse() + return True + + def _set_status_updated(self, model, data): + pass + + def _set_status_finished(self, model, data=None): + self._message.set_markup(self._finished_message) + + self._progress_bar.hide() + self._start_button.hide() + + if self._restart_after: + self._restart_button.show() + else: + self._close_button.show() + + def _set_status_failed(self, model=None, error_message=''): + self._message.set_markup('%s %s' % (self._failed_message, error_message)) + + self._progress_bar.hide() + self._start_button.show() + self._close_button.show() + self._restart_button.hide() + + logging.error(error_message) + + +class VolumeBackupDialog(ProcessDialog): + + def __init__(self, volume_path): + ProcessDialog.__init__(self, 'journal-backup-volume', \ + [volume_path, misc.get_backup_identifier()], restart_after=False) + + self._resetup_information(volume_path) + + def _resetup_information(self, volume_path): + self._start_message = '%s %s. \n\n' % (_('Please wait, saving Journal content to'), volume_path) + \ + '<big><b>%s</b></big>' % _('Do not remove the storage device!') + + self._finished_message = _('The Journal content has been saved.') + + self._title.set_markup('<big><b>%s</b></big>' % _('Backup')) + + self._message.set_markup('%s %s' % (_('Journal content will be saved to'), volume_path)) + + +class VolumeRestoreDialog(ProcessDialog): + + def __init__(self, volume_path): + ProcessDialog.__init__(self, 'journal-restore-volume', \ + [volume_path, misc.get_backup_identifier()]) + + self._resetup_information(volume_path) + + def _resetup_information(self, volume_path): + self._start_message = '%s %s. \n\n' % (_('Please wait, restoring Journal content from'), volume_path) + \ + '<big><b>%s</b></big>' % _('Do not remove the storage device!') + + self._finished_message = _('The Journal content has been restored.') + + self._title.set_markup('<big><b>%s</b></big>' % _('Restore')) + + self._message.set_markup('%s %s.\n\n' % (_('Journal content will be restored from'), volume_path) + \ + '<big><b>%s</b> %s</big>' % (_('Warning:'), _('Current Journal content will be deleted!'))) + + self._prerequisite_message = _(', please close all the running activities.') + + def _check_prerequisites(self): + return len(shell.get_model()) <= 2 diff --git a/src/jarabe/journal/volumestoolbar.py b/src/jarabe/journal/volumestoolbar.py index 1fc368e..1bf81bb 100644 --- a/src/jarabe/journal/volumestoolbar.py +++ b/src/jarabe/journal/volumestoolbar.py @@ -37,11 +37,14 @@ from sugar3.graphics.xocolor import XoColor from sugar3 import env from jarabe.journal import model -from jarabe.view.palettes import VolumePalette +from jarabe.view.palettes import JournalVolumePalette, RemoteSharePalette _JOURNAL_0_METADATA_DIR = '.olpc.store' +SHARE_TYPE_PEER = 1 +SHARE_TYPE_SCHOOL_SERVER = 2 + def _get_id(document): """Get the ID for the document in the xapian database.""" @@ -193,6 +196,17 @@ class VolumesToolbar(Gtk.Toolbar): def _set_up_volumes(self): self._set_up_documents_button() + if model.is_peer_to_peer_sharing_available(): + self._set_up_local_shares_button() + + client = GConf.Client.get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + + if model.is_school_server_present(): + self._add_remote_share_button(_('School-Server Shares'), + model.SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME, + color, SHARE_TYPE_SCHOOL_SERVER) + volume_monitor = Gio.VolumeMonitor.get() self._mount_added_hid = volume_monitor.connect('mount-added', self.__mount_added_cb) @@ -202,12 +216,11 @@ class VolumesToolbar(Gtk.Toolbar): for mount in volume_monitor.get_mounts(): self._add_button(mount) - def _set_up_documents_button(self): - documents_path = model.get_documents_path() - if documents_path is not None: - button = DocumentsButton(documents_path) + def _set_up_directory_button(self, dir_path, icon_name, label_text): + if dir_path is not None: + button = DirectoryButton(dir_path, icon_name) button.props.group = self._volume_buttons[0] - label = glib.markup_escape_text(_('Documents')) + label = glib.markup_escape_text(label_text) button.set_palette(Palette(label)) button.connect('toggled', self._button_toggled_cb) button.show() @@ -217,6 +230,44 @@ class VolumesToolbar(Gtk.Toolbar): self._volume_buttons.append(button) self.show() + def _set_up_documents_button(self): + documents_path = model.get_documents_path() + self._set_up_directory_button(documents_path, + 'user-documents', + _('Documents')) + + def _set_up_local_shares_button(self): + local_shares_path = model.LOCAL_SHARES_MOUNT_POINT + self._set_up_directory_button(local_shares_path, + 'emblem-neighborhood-shared', + _('Local Shares')) + + def _add_remote_share_button(self, primary_text, + ip_address_or_dns_name, color, + share_type): + button = RemoteSharesButton(primary_text, ip_address_or_dns_name, + color, share_type) + button._share_type = share_type + button.props.group = self._volume_buttons[0] + + show_unmount_option = None + if share_type == SHARE_TYPE_PEER: + show_unmount_option = True + else: + show_unmount_option = False + button.set_palette(RemoteSharePalette(primary_text, + ip_address_or_dns_name, button, + show_unmount_option)) + button.connect('toggled', self._button_toggled_cb) + button.show() + + position = self.get_item_index(self._volume_buttons[-1]) + 1 + self.insert(button, position) + self._volume_buttons.append(button) + self.show() + + return button + def __mount_added_cb(self, volume_monitor, mount): self._add_button(mount) @@ -247,10 +298,26 @@ class VolumesToolbar(Gtk.Toolbar): def __volume_error_cb(self, button, strerror, severity): self.emit('volume-error', strerror, severity) - def _button_toggled_cb(self, button): - if button.props.active: + def _button_toggled_cb(self, button, force_toggle=False): + if button.props.active or force_toggle: + button.set_active(True) + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.hide_alert() + journal.get_list_view()._selected_entries = 0 + journal.switch_to_editing_mode(False) + journal.get_list_view().inhibit_refresh(False) + self.emit('volume-changed', button.mount_point) + def _unmount_activated_cb(self, menu_item, mount): + logging.debug('VolumesToolbar._unmount_activated_cb: %r', mount) + mount.unmount(self.__unmount_cb) + + def __unmount_cb(self, source, result): + logging.debug('__unmount_cb %r %r', source, result) + def _get_button_for_mount(self, mount): mount_point = mount.get_root().get_path() for button in self.get_children(): @@ -259,6 +326,13 @@ class VolumesToolbar(Gtk.Toolbar): logging.error('Couldnt find button with mount_point %r', mount_point) return None + def _get_button_for_mount_point(self, mount_point): + for button in self.get_children(): + if button.mount_point == mount_point: + return button + logging.error('Couldnt find button with mount_point %r', mount_point) + return None + def _remove_button(self, mount): button = self._get_button_for_mount(mount) self._volume_buttons.remove(button) @@ -268,10 +342,33 @@ class VolumesToolbar(Gtk.Toolbar): if len(self.get_children()) < 2: self.hide() + def _remove_remote_share_button(self, ip_address_or_dns_name): + for button in self.get_children(): + if type(button) == RemoteSharesButton and \ + button.mount_point == (model.WEBDAV_MOUNT_POINT + ip_address_or_dns_name): + self._volume_buttons.remove(button) + self.remove(button) + + from jarabe.journal.webdavmanager import \ + unmount_share_from_backend + unmount_share_from_backend(ip_address_or_dns_name) + + self.get_children()[0].props.active = True + + if len(self.get_children()) < 2: + self.hide() + break; + def set_active_volume(self, mount): button = self._get_button_for_mount(mount) button.props.active = True + def get_journal_button(self): + return self._volume_buttons[0] + + def get_button_toggled_cb(self): + return self._button_toggled_cb + class BaseButton(RadioToolButton): __gsignals__ = { @@ -291,23 +388,36 @@ class BaseButton(RadioToolButton): def _drag_data_received_cb(self, widget, drag_context, x, y, selection_data, info, timestamp): + # Disallow copying to mounted-shares for peers. + if (model.is_mount_point_for_locally_mounted_remote_share(self.mount_point)) and \ + (model.is_mount_point_for_peer_share(self.mount_point)): + from jarabe.journal.journalactivity import get_journal + + journal = get_journal() + journal._show_alert(_('Entries cannot be copied to Peer-Shares.'), _('Error')) + return + object_id = selection_data.data metadata = model.get(object_id) - file_path = model.get_file(metadata['uid']) - if not file_path or not os.path.exists(file_path): - logging.warn('Entries without a file cannot be copied.') - self.emit('volume-error', - _('Entries without a file cannot be copied.'), - _('Warning')) - return - try: - model.copy(metadata, self.mount_point) - except IOError, e: - logging.exception('Error while copying the entry. %s', e.strerror) - self.emit('volume-error', - _('Error while copying the entry. %s') % e.strerror, - _('Error')) + from jarabe.journal.palettes import CopyMenu, get_copy_menu_helper + copy_menu_helper = get_copy_menu_helper() + + dummy_copy_menu = CopyMenu() + copy_menu_helper.insert_copy_to_menu_items(dummy_copy_menu, + [metadata], + False, + False, + False) + + # Now, activate the menuitem, whose mount-point matches the + # mount-point of the button, upon whom the item has been + # dragged. + children_menu_items = dummy_copy_menu.get_children() + for child in children_menu_items: + if child.get_mount_point() == self.mount_point: + child.activate() + return class VolumeButton(BaseButton): @@ -335,7 +445,7 @@ class VolumeButton(BaseButton): self.props.xo_color = color def create_palette(self): - palette = VolumePalette(self._mount) + palette = JournalVolumePalette(self._mount) #palette.props.invoker = FrameWidgetInvoker(self) #palette.set_group_id('frame') return palette @@ -386,13 +496,35 @@ class JournalButtonPalette(Palette): {'free_space': free_space / (1024 * 1024)} -class DocumentsButton(BaseButton): +class DirectoryButton(BaseButton): - def __init__(self, documents_path): - BaseButton.__init__(self, mount_point=documents_path) + def __init__(self, dir_path, icon_name): + BaseButton.__init__(self, mount_point=dir_path) + self._mount = dir_path - self.props.icon_name = 'user-documents' + self.props.icon_name = icon_name client = GConf.Client.get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) self.props.xo_color = color + + +class RemoteSharesButton(BaseButton): + + def __init__(self, primary_text, ip_address_or_dns_name, color, + share_type): + BaseButton.__init__(self, mount_point=(model.WEBDAV_MOUNT_POINT + ip_address_or_dns_name)) + + self._primary_text = primary_text + self._ip_address_or_dns_name = ip_address_or_dns_name + + if share_type == SHARE_TYPE_PEER: + self.props.icon_name = 'emblem-neighborhood-shared' + elif share_type == SHARE_TYPE_SCHOOL_SERVER: + self.props.icon_name = 'school-server' + self.props.xo_color = color + + def create_palette(self): + palette = RemoteSharePalette(self._primary_text, self._ip_address_or_dns_name, + self, True) + return palette diff --git a/src/jarabe/journal/webdavmanager.py b/src/jarabe/journal/webdavmanager.py new file mode 100644 index 0000000..6936239 --- /dev/null +++ b/src/jarabe/journal/webdavmanager.py @@ -0,0 +1,312 @@ +from gettext import gettext as _ + +from gi.repository import GObject + +import logging +import os +import sys + +import simplejson +import shutil + +from webdav.Connection import AuthorizationError, WebdavError +from webdav.WebdavClient import CollectionStorer + +def get_key_from_resource(resource): + return resource.path + +class WebDavUrlManager(GObject.GObject): + """ + This class holds all data, relevant to a WebDavUrl. + + One thing must be noted, that a valid WebDavUrl is the one which + may contain zero or more resources (files), or zero or more + collections (directories). + + Thus, following are valid WebDavUrls :: + + dav://1.2.3.4/webdav + dav://1.2.3.4/webdav/dir_1 + dav://1.2.3.4/webdav/dir_1/dir_2 + + but following are not :: + + dav://1.2.3.4/webdav/a.txt + dav://1.2.3.4/webdav/dir_1/b.jpg + dav://1.2.3.4/webdab/dir_1/dir_2/c.avi + """ + + def __init__(self, WebDavUrl, username, password): + self._WebDavUrl = WebDavUrl + self._username = username + self._password = password + + def _get_key_from_resource(self, resource): + return resource.path.encode(sys.getfilesystemencoding()) + + def _get_number_of_collections(self): + return len(self._remote_webdav_share_collections) + + def _get_root(self): + return self._root + + def _get_resources_dict(self): + return self._remote_webdav_share_resources + + def _get_collections_dict(self): + return self._remote_webdav_share_collections + + def _get_resource_by_key(self, key): + if key in self._remote_webdav_share_resources.keys(): + return self._remote_webdav_share_resources[key]['resource'] + return None + + def _add_or_replace_resource_by_key(self, key, resource): + self._remote_webdav_share_resources[key] = {} + self._remote_webdav_share_resources[key]['resource'] = resource + + def _get_metadata_list(self): + metadata_list = [] + for key in self._remote_webdav_share_resources.keys(): + metadata_list.append(self._remote_webdav_share_resources[key]['metadata']) + return metadata_list + + def _get_live_properties(self, resource_key): + resource_container = self._remote_webdav_share_resources[resource_key] + return resource_container['webdav-properties'] + + def _fetch_resources_and_collections(self): + webdavConnection = CollectionStorer(self._WebDavUrl, validateResourceNames=False) + self._root = webdavConnection + + authFailures = 0 + while authFailures < 2: + try: + self._remote_webdav_share_resources = {} + self._remote_webdav_share_collections = {} + + try: + self._collection_contents = webdavConnection.getCollectionContents() + for resource, properties in self._collection_contents: + try: + key = self._get_key_from_resource(resource) + selected_dict = None + + if properties.getResourceType() == 'resource': + selected_dict = self._remote_webdav_share_resources + else: + selected_dict = self._remote_webdav_share_collections + + selected_dict[key] = {} + selected_dict[key]['resource'] = resource + selected_dict[key]['webdav-properties'] = properties + except UnicodeEncodeError: + print("Cannot encode resource path or properties.") + + return True + + except WebdavError, e: + # Note that, we need to deal with all errors, + # except "AuthorizationError", as that is not + # really an error from our perspective. + if not type(e) == AuthorizationError: + from jarabe.journal.journalactivity import get_journal + + from jarabe.journal.palettes import USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + error_message = USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + get_journal()._volume_error_cb(None, error_message,_('Error')) + + # Re-raise this error. + # Note that since this is not an + # "AuthorizationError", this will not be caught + # by the outer except-block. Instead, it will + # navigate all the way back up, and will report + # the error in the enclosing except block. + raise e + + else: + # If this indeed is an Authorization Error, + # re-raise it, so that it is caught by the outer + # "except" block. + raise e + + + except AuthorizationError, e: + if self._username is None or self._password is None: + raise Exception("WebDav username or password is None. Please specify appropriate values.") + + if e.authType == "Basic": + webdavConnection.connection.addBasicAuthorization(self._username, self._password) + elif e.authType == "Digest": + info = parseDigestAuthInfo(e.authInfo) + webdavConnection.connection.addDigestAuthorization(self._username, self._password, realm=info["realm"], qop=info["qop"], nonce=info["nonce"]) + else: + raise + authFailures += 1 + + return False + +webdav_manager = {} + +def get_data_webdav_manager(ip_address_or_dns_name): + return webdav_manager[ip_address_or_dns_name]['data'] + + +def get_metadata_webdav_manager(ip_address_or_dns_name): + return webdav_manager[ip_address_or_dns_name]['metadata'] + + +def get_resource_by_resource_key(root_webdav, key): + resources_dict = root_webdav._get_resources_dict() + if key in resources_dict.keys(): + resource_dict = resources_dict[key] + resource = resource_dict['resource'] + return resource + return None + + +def add_resource_by_resource_key(root_webdav, key, + content_file_path): + root = root_webdav._get_root() + + resource = root.addResource(key) + + # Procure the resource-lock. + lockToken = resource.lock('olpc') + + input_stream = open(content_file_path) + + # Now, upload the data; but it's necessary to enclose this in a + # try-except-finally block here, since we need to close the + # input-stream, whatever may happen. + try: + resource.uploadFile(input_stream, lockToken) + root_webdav._add_or_replace_resource_by_key(key, resource) + except Exception, e: + logging.exception(e) + resource.delete(lockToken) + raise e + else: + resource.unlock(lockToken) + finally: + input_stream.close() + + +def get_remote_webdav_share_metadata(ip_address_or_dns_name): + protocol = 'davs://' + root_webdav_url = '/webdav' + complete_root_url = protocol + ip_address_or_dns_name + root_webdav_url + + root_webdav = WebDavUrlManager(complete_root_url, 'test', 'olpc') + if root_webdav._fetch_resources_and_collections() is False: + # Return empty metadata list. + return [] + + # Keep reference to the "WebDavUrlManager", keyed by IP-Address. + global webdav_manager + webdav_manager[ip_address_or_dns_name] = {} + webdav_manager[ip_address_or_dns_name]['data'] = root_webdav + + + # Assert that the number of collections is only one at this url + # (i.e. only ".Sugar-Metadata" is present). + assert root_webdav._get_number_of_collections() == 1 + + root_sugar_metadata_url = root_webdav_url + '/.Sugar-Metadata' + + complete_root_sugar_metadata_url = protocol + ip_address_or_dns_name + root_sugar_metadata_url + root_webdav_sugar_metadata = WebDavUrlManager(complete_root_sugar_metadata_url, 'test', 'olpc') + if root_webdav_sugar_metadata._fetch_resources_and_collections() is False: + # Return empty metadata list. + return [] + + webdav_manager[ip_address_or_dns_name]['metadata'] = root_webdav_sugar_metadata + + # assert that the number of collections is zero at this url. + assert root_webdav_sugar_metadata._get_number_of_collections() == 0 + + # Now. associate sugar-metadata with each of the "root-webdav" + # resource. + root_webdav_resources = root_webdav._get_resources_dict() + root_webdav_sugar_metadata_resources = root_webdav_sugar_metadata._get_resources_dict() + + # Prepare the metadata-download folder. + downloaded_data_root_dir = '/tmp/' + ip_address_or_dns_name + downloaded_metadata_file_dir = downloaded_data_root_dir + '/.Sugar-Metadata' + if os.path.isdir(downloaded_data_root_dir): + shutil.rmtree(downloaded_data_root_dir) + os.makedirs(downloaded_metadata_file_dir) + + metadata_list = [] + + # Note that the presence of a resource in the metadata directory, + # is the only assurance of the entry (and its constituents) being + # present in entirety. Thus, always proceed taking the metadata as + # the "key". + for root_webdav_sugar_metadata_resource_name in root_webdav_sugar_metadata_resources.keys(): + """ + root_webdav_sugar_metadata_resource_name is of the type :: + + /webdav/.Sugar-Metadata/a.txt.metadata, OR + /webdav/.Sugar-Metadata/a.txt.preview + """ + + # If this is a "preview" resource, continue forward, as we only + # want the metadata list. The "preview" resources are anyways + # already present in the manager DS. + if root_webdav_sugar_metadata_resource_name.endswith('.preview'): + continue + + split_tokens_array = root_webdav_sugar_metadata_resource_name.split('/') + + # This will provide us with "a.txt.metadata" + sugar_metadata_basename = split_tokens_array[len(split_tokens_array) - 1] + + # This will provide us with "a.txt" + basename = sugar_metadata_basename[0:sugar_metadata_basename.index('.metadata')] + + downloaded_metadata_file_path = downloaded_metadata_file_dir + '/' + sugar_metadata_basename + metadata_resource = \ + root_webdav_sugar_metadata._get_resource_by_key(root_webdav_sugar_metadata_resource_name) + metadata_resource.downloadFile(downloaded_metadata_file_path) + + + # We need to download the preview-file as well at this stage, + # so that it can be shown in the expanded entry. + downloaded_preview_file_path = downloaded_metadata_file_dir + \ + '/' + basename + '.preview' + root_webdav_sugar_preview_resource_name = \ + root_webdav_sugar_metadata_resource_name[0:root_webdav_sugar_metadata_resource_name.index('.metadata')] + \ + '.preview' + preview_resource = \ + root_webdav_sugar_metadata._get_resource_by_key(root_webdav_sugar_preview_resource_name) + if preview_resource is not None: + preview_resource.downloadFile(downloaded_preview_file_path) + + + file_pointer = open(downloaded_metadata_file_path) + metadata = eval(file_pointer.read()) + file_pointer.close() + + # Fill in the missing metadata properties. + # Note that the file is not physically present. + metadata['uid'] = downloaded_data_root_dir + '/' + basename + metadata['creation_time'] = metadata['timestamp'] + + # Now, write this to the metadata-file, so that + # webdav-properties get gelled into sugar-metadata. + file_pointer = open(downloaded_metadata_file_path, 'w') + file_pointer.write(simplejson.dumps(metadata)) + file_pointer.close() + + metadata_list.append(metadata) + + return metadata_list + + +def is_remote_webdav_loaded(ip_address_or_dns_name): + return ip_address_or_dns_name in webdav_manager.keys() + + +def unmount_share_from_backend(ip_address_or_dns_name): + del webdav_manager[ip_address_or_dns_name] diff --git a/src/jarabe/model/Makefile.am b/src/jarabe/model/Makefile.am index 2fc6b1c..d40fb8d 100644 --- a/src/jarabe/model/Makefile.am +++ b/src/jarabe/model/Makefile.am @@ -12,6 +12,7 @@ sugar_PYTHON = \ neighborhood.py \ network.py \ notifications.py \ + processmanagement.py \ shell.py \ screen.py \ session.py \ diff --git a/src/jarabe/model/neighborhood.py b/src/jarabe/model/neighborhood.py index 85c35c9..0712073 100644 --- a/src/jarabe/model/neighborhood.py +++ b/src/jarabe/model/neighborhood.py @@ -427,7 +427,8 @@ class _Account(GObject.GObject): def __buddy_info_updated_cb(self, handle, properties): logging.debug('_Account.__buddy_info_updated_cb %r', handle) - self.emit('buddy-updated', self._buddy_handles[handle], properties) + if handle in self._buddy_handles: + self.emit('buddy-updated', self._buddy_handles[handle], properties) def __current_activity_changed_cb(self, contact_handle, activity_id, room_handle): @@ -929,6 +930,9 @@ class Neighborhood(GObject.GObject): if 'key' in properties: buddy.props.key = properties['key'] + if 'ip4-address' in properties: + buddy.props.ip_address = properties['ip4-address'] + nick_key = CONNECTION_INTERFACE_ALIASING + '/alias' if nick_key in properties: buddy.props.nick = properties[nick_key] diff --git a/src/jarabe/model/network.py b/src/jarabe/model/network.py index 53e170a..c60f6af 100644 --- a/src/jarabe/model/network.py +++ b/src/jarabe/model/network.py @@ -492,6 +492,7 @@ class Settings(object): self.connection = ConnectionSettings() self.ip4_config = None self.wireless_security = None + self.wpa_eap_setting = None if wireless_cfg is not None: self.wireless = wireless_cfg @@ -507,6 +508,8 @@ class Settings(object): self.wireless_security.get_dict() if self.ip4_config is not None: settings['ipv4'] = self.ip4_config.get_dict() + if self.wpa_eap_setting is not None: + settings['802-1x'] = self.wpa_eap_setting return settings @@ -907,12 +910,14 @@ def activate_connection_by_path(connection, device_o, error_handler=error_handler) -def add_and_activate_connection(device_o, settings, specific_object): +def add_and_activate_connection(device_o, settings, specific_object, + reply_handler=_add_and_activate_reply_cb, + error_handler=_add_and_activate_error_cb): manager = get_manager() manager.AddAndActivateConnection(settings.get_dict(), device_o, specific_object, - reply_handler=_add_and_activate_reply_cb, - error_handler=_add_and_activate_error_cb) + reply_handler=reply_handler, + error_handler=error_handler) def _migrate_old_wifi_connections(): diff --git a/src/jarabe/model/processmanagement.py b/src/jarabe/model/processmanagement.py new file mode 100644 index 0000000..cb429f6 --- /dev/null +++ b/src/jarabe/model/processmanagement.py @@ -0,0 +1,120 @@ +# Copyright (C) 2010, Paraguay Educa <tecnologia@paraguayeduca.org> +# Copyright (C) 2010, Plan Ceibal <comunidad@plan.ceibal.edu.uy> +# +# 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 +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from gi.repository import GObject +from gi.repository import Gio + +import os +import glib + + +from sugar import env +from gettext import gettext as _ + +BYTES_TO_READ = 100 + +class ProcessManagement(GObject.GObject): + + __gtype_name__ = 'ProcessManagement' + + __gsignals__ = { + 'process-management-running' : (GObject.SignalFlags.RUN_FIRST, None, ([str])), + 'process-management-started' : (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'process-management-finished' : (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'process-management-failed' : (GObject.SignalFlags.RUN_FIRST, None, ([str])) + } + + def __init__(self): + GObject.GObject.__init__(self) + self._running = False + + def do_process(self, cmd): + self._run_cmd_async(cmd) + + def _report_process_status(self, stream, result, user_data=None): + data = stream.read_finish(result) + + if data != 0: + self.emit('process-management-running', data) + stream.read_async([], + BYTES_TO_READ, + GObject.PRIORITY_LOW, + None, + self._report_process_status, + None) + + def _report_process_error(self, stream, result, concat_err=''): + data = stream.read_finish(result) + concat_err = concat_err + data + + if data != 0: + self.emit('process-management-failed', concat_err) + else: + stream.read_async([], + BYTES_TO_READ, + GObject.PRIORITY_LOW, + None, + self._report_process_error, + concat_err) + + def _notify_error(self, stderr): + stdin_stream = Gio.UnixInputStream(fd=stderr, close_fd=True) + stdin_stream.read_async([], + BYTES_TO_READ, + GObject.PRIORITY_LOW, + None, + self._report_process_error, + None) + + def _notify_process_status(self, stdout): + stdin_stream = Gio.UnixInputStream(fd=stdout, close_fd=True) + stdin_stream.read_async([], + BYTES_TO_READ, + GObject.PRIORITY_LOW, + None, + self._report_process_status, + None) + + def _run_cmd_async(self, cmd): + if self._running == False: + try: + pid, stdin, stdout, stderr = glib.spawn_async(cmd, flags=glib.SPAWN_DO_NOT_REAP_CHILD, standard_output=True, standard_error=True) + GObject.child_watch_add(pid, _handle_process_end, (self, stderr)) + except Exception: + self.emit('process-management-failed', _("Error - Call process: ") + str(cmd)) + else: + self._notify_process_status(stdout) + self._running = True + self.emit('process-management-started') + +def _handle_process_end(pid, condition, (myself, stderr)): + myself._running = False + + if os.WIFEXITED(condition) and\ + os.WEXITSTATUS(condition) == 0: + myself.emit('process-management-finished') + else: + myself._notify_error(stderr) + +def find_and_absolutize(script_name): + paths = env.os.environ['PATH'].split(':') + for path in paths: + looking_path = path + '/' + script_name + if env.os.path.isfile(looking_path): + return looking_path + + return None diff --git a/src/jarabe/view/buddymenu.py b/src/jarabe/view/buddymenu.py index d17f4ff..93790fd 100644 --- a/src/jarabe/view/buddymenu.py +++ b/src/jarabe/view/buddymenu.py @@ -73,6 +73,18 @@ class BuddyMenu(Palette): self.menu.append(menu_item) menu_item.show() + remote_share_menu_item = None + from jarabe.journal import webdavmanager + if not webdavmanager.is_remote_webdav_loaded(self._buddy.props.ip_address): + remote_share_menu_item = MenuItem(_('Access Share'), 'list-add') + remote_share_menu_item.connect('activate', self._access_share_cb) + else: + remote_share_menu_item = MenuItem(_('Unmount Share'), 'list-remove') + remote_share_menu_item.connect('activate', self.__unmount_cb) + + self.menu.append(remote_share_menu_item) + remote_share_menu_item.show() + self._invite_menu = MenuItem('') self._invite_menu.connect('activate', self._invite_friend_cb) self.menu.append(self._invite_menu) diff --git a/src/jarabe/view/palettes.py b/src/jarabe/view/palettes.py index 10844ea..bbbf822 100644 --- a/src/jarabe/view/palettes.py +++ b/src/jarabe/view/palettes.py @@ -1,4 +1,6 @@ # Copyright (C) 2008 One Laptop Per Child +# Copyright (C) 2010, Plan Ceibal <comunidad@plan.ceibal.edu.uy> +# Copyright (C) 2010, Paraguay Educa <tecnologia@paraguayeduca.org> # # 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 @@ -33,6 +35,7 @@ from sugar3.graphics import style from sugar3.graphics.xocolor import XoColor from sugar3.activity.i18n import pgettext +from jarabe.journal.processdialog import VolumeBackupDialog, VolumeRestoreDialog from jarabe.model import shell from jarabe.view.viewsource import setup_view_source from jarabe.journal import misc @@ -268,3 +271,97 @@ class VolumePalette(Palette): self._progress_bar.props.fraction = fraction self._free_space_label.props.label = _('%(free_space)d MB Free') % \ {'free_space': free_space / (1024 * 1024)} + + +class RemoteSharePalette(Palette): + def __init__(self, primary_text, ip_address_or_dns_name, button, + show_unmount_option): + Palette.__init__(self, label=primary_text) + self._button = button + self._ip_address_or_dns_name = ip_address_or_dns_name + + self.props.secondary_text = \ + glib.markup_escape_text(self._ip_address_or_dns_name) + + vbox = Gtk.VBox() + self.set_content(vbox) + vbox.show() + + self.connect('popup', self.__popup_cb) + + menu_item = PaletteMenuItem(pgettext('Share', _('Reload'))) + icon = Icon(icon_name='system-restart', icon_size=Gtk.IconSize.MENU) + menu_item.set_image(icon) + icon.show() + + menu_item.connect('activate', self.__reload_remote_share) + vbox.add(menu_item) + menu_item.show() + + + if show_unmount_option == True: + menu_item = PaletteMenuItem(pgettext('Share', 'Unmount')) + icon = Icon(icon_name='media-eject', icon_size=gtk.ICON_SIZE_MENU) + menu_item.set_image(icon) + icon.show() + + menu_item.connect('activate', self.__unmount_activate_cb) + vbox.add(menu_item) + menu_item.show() + + def __reload_remote_share(self, menu_item): + from jarabe.journal.journalactivity import get_journal + get_journal().hide_alert() + get_journal().get_list_view().refresh() + + def __unmount_activate_cb(self, menu_item): + from jarabe.journal.journalactivity import get_journal + + singleton_volumes_toolbar = get_journal().get_volumes_toolbar() + singleton_volumes_toolbar._remove_remote_share_button(self._ip_address_or_dns_name) + + def __popup_cb(self, palette): + pass + + + + +class JournalVolumePalette(VolumePalette): + + __gtype_name__ = 'JournalVolumePalette' + + def __init__(self, mount): + VolumePalette.__init__(self, mount) + + journal_separator = gtk.SeparatorMenuItem() + journal_separator.show() + + self.menu.prepend(journal_separator) + + icon = Icon(icon_name='transfer-from', icon_size=gtk.ICON_SIZE_MENU) + icon.show() + + menu_item_journal_restore = MenuItem(_('Restore Journal')) + menu_item_journal_restore.set_image(icon) + menu_item_journal_restore.connect('activate', self.__journal_restore_activate_cb, mount.get_root().get_path()) + menu_item_journal_restore.show() + + self.menu.prepend(menu_item_journal_restore) + + icon = Icon(icon_name='transfer-to', icon_size=gtk.ICON_SIZE_MENU) + icon.show() + + menu_item_journal_backup = MenuItem(_('Backup Journal')) + menu_item_journal_backup.set_image(icon) + menu_item_journal_backup.connect('activate', self.__journal_backup_activate_cb, mount.get_root().get_path()) + menu_item_journal_backup.show() + + self.menu.prepend(menu_item_journal_backup) + + def __journal_backup_activate_cb(self, menu_item, mount_path): + dialog = VolumeBackupDialog(mount_path) + dialog.show() + + def __journal_restore_activate_cb(self, menu_item, mount_path): + dialog = VolumeRestoreDialog(mount_path) + dialog.show() diff --git a/src/jarabe/view/pulsingicon.py b/src/jarabe/view/pulsingicon.py index 81e2e03..652e22e 100644 --- a/src/jarabe/view/pulsingicon.py +++ b/src/jarabe/view/pulsingicon.py @@ -90,12 +90,23 @@ class PulsingIcon(Icon): self._pulse_color = None self._paused = False self._pulsing = False + self._timeout = 0 + self._pulsing_sid = None Icon.__init__(self, **kwargs) self._palette = None self.connect('destroy', self.__destroy_cb) + def set_timeout(self, timeout): + self._timeout = timeout + + def get_timeout(self): + return self._timeout + + timeout = GObject.property( + type=int, getter=get_timeout, setter=set_timeout) + def set_pulse_color(self, pulse_color): self._pulse_color = pulse_color self._pulser.update() @@ -142,10 +153,20 @@ class PulsingIcon(Icon): type=bool, default=False, getter=get_paused, setter=set_paused) def set_pulsing(self, pulsing): + if self._pulsing == pulsing: + return + + if self._pulsing_sid is not None: + GObject.source_remove(self._pulsing_sid) + self._pulsing_sid = None + self._pulsing = pulsing if self._pulsing: self._pulser.start(restart=True) + if self.props.timeout > 0: + self._pulsing_sid = GObject.timeout_add_seconds( + self.props.timeout, self.__timeout_cb) else: self._pulser.stop() @@ -165,6 +186,9 @@ class PulsingIcon(Icon): palette = property(_get_palette, _set_palette) + def __timeout_cb(self): + self.props.pulsing = False + def __destroy_cb(self, icon): self._pulser.stop() if self._palette is not None: |