changeset 86:c711be670ecd

core, chat: upload plugin system: - extented plugin system so it's not only used with main widget. It is also used for upload widgets and can be extended more - plugin file name is used to detect the type: plugin_wid_* for main widgets, plugin_upload_* for upload widget plugins - a new UploadMenu class allows to easily add an upload button which will use loaded plugins - plugin_info can now specify a list of allowed platforms in "platforms" key - file upload in chat has been moved to a plugin
author Goffi <goffi@goffi.org>
date Sun, 25 Dec 2016 16:41:21 +0100
parents c2a7234d13d2
children 17094a075fd2
files src/cagou/core/cagou_main.py src/cagou/core/constants.py src/cagou/core/menu.py src/cagou/kv/menu.kv src/cagou/plugins/plugin_upload_file.py src/cagou/plugins/plugin_wid_chat.kv src/cagou/plugins/plugin_wid_chat.py
diffstat 7 files changed, 220 insertions(+), 79 deletions(-) [+]
line wrap: on
line diff
--- a/src/cagou/core/cagou_main.py	Sat Dec 24 14:20:49 2016 +0100
+++ b/src/cagou/core/cagou_main.py	Sun Dec 25 16:41:21 2016 +0100
@@ -56,7 +56,7 @@
 from cagou_widget import CagouWidget
 from . import widgets_handler
 from .common import IconButton
-from .menu import MenusWidget
+from . import menu
 from importlib import import_module
 import os.path
 import glob
@@ -152,7 +152,7 @@
         self.manager.switch_to(screen)
 
 
-class RootMenus(MenusWidget):
+class RootMenus(menu.MenusWidget):
     pass
 
 
@@ -292,7 +292,8 @@
         self.app.host = self
         self.media_dir = self.app.media_dir = config.getConfig(main_config, '', 'media_dir')
         self.app.default_avatar = os.path.join(self.media_dir, "misc/default_avatar.png")
-        self._plg_wids = []  # widget plugins
+        self._plg_wids = []  # main widgets plugins
+        self._plg_wids_upload = []  # upload widgets plugins
         self._import_plugins()
         self._visible_widgets = {}  # visible widgets by classes
 
@@ -343,11 +344,30 @@
         del self.app._profile_manager
         super(Cagou, self).postInit(profile_manager)
 
-    def _defaultFactory(self, plugin_info, target, profiles):
-        """factory used to create widget instance when PLUGIN_INFO["factory"] is not set"""
+    def _defaultFactoryMain(self, plugin_info, target, profiles):
+        """default factory used to create main widgets instances
+
+        used when PLUGIN_INFO["factory"] is not set
+        @param plugin_info(dict): plugin datas
+        @param target: QuickWidget target
+        @param profiles(iterable): list of profiles
+        """
         main_cls = plugin_info['main']
         return self.widgets.getOrCreateWidget(main_cls, target, on_new_widget=None, profiles=iter(self.profiles))
 
+    def _defaultFactoryUpload(self, plugin_info, callback, cancel_cb, profiles):
+        """default factory used to create upload widgets instances
+
+        @param plugin_info(dict): plugin datas
+        @param callback(callable): method to call with path to file to upload
+        @param cancel_cb(callable): call when upload is cancelled
+            upload widget must be used as first argument
+        @param profiles(iterable): list of profiles
+            None if not specified
+        """
+        main_cls = plugin_info['main']
+        return main_cls(callback=callback, cancel_cb=cancel_cb)
+
     ## plugins & kv import ##
 
     def _import_kv(self):
@@ -369,29 +389,65 @@
             plugin_glob = "plugin*.py"
         plug_lst = [os.path.splitext(p)[0] for p in map(os.path.basename, glob.glob(os.path.join(plugins_path, plugin_glob)))]
 
-        imported_names = set()  # use to avoid loading 2 times plugin with same import name
+        imported_names_main = set()  # used to avoid loading 2 times plugin with same import name
+        imported_names_upload = set()
         for plug in plug_lst:
             plugin_path = 'cagou.plugins.' + plug
+
+            # we get type from plugin name
+            suff = plug[7:]
+            if u'_' not in suff:
+                log.error(u"invalid plugin name: {}, skipping".format(plug))
+                continue
+            plugin_type = suff[:suff.find(u'_')]
+
+            # and select the variable to use according to type
+            if plugin_type == C.PLUG_TYPE_WID:
+                imported_names = imported_names_main
+                default_factory = self._defaultFactoryMain
+            elif plugin_type == C.PLUG_TYPE_UPLOAD:
+                imported_names = imported_names_upload
+                default_factory = self._defaultFactoryUpload
+            else:
+                log.error(u"unknown plugin type {type_} for plugin {file_}, skipping".format(
+                    type_ = plugin_type,
+                    file_ = plug
+                    ))
+                continue
+            plugins_set = self._getPluginsSet(plugin_type)
+
             mod = import_module(plugin_path)
             try:
                 plugin_info = mod.PLUGIN_INFO
             except AttributeError:
                 plugin_info = {}
 
+            plugin_info['plugin_file'] = plug
+            plugin_info['plugin_type'] = plugin_type
+
+            if 'platforms' in plugin_info:
+                if sys.platform not in plugin_info['platforms']:
+                    log.info(u"{plugin_file} is not used on this platform, skipping".format(**plugin_info))
+                    continue
+
             # import name is used to differentiate plugins
             if 'import_name' not in plugin_info:
                 plugin_info['import_name'] = plug
-            if 'import_name' in imported_names:
+            if plugin_info['import_name'] in imported_names:
                 log.warning(_(u"there is already a plugin named {}, ignoring new one").format(plugin_info['import_name']))
                 continue
             if plugin_info['import_name'] == C.WID_SELECTOR:
+                if plugin_type != C.PLUG_TYPE_WID:
+                    log.error(u"{import_name} import name can only be used with {type_} type, skipping {name}".format(type_=C.PLUG_TYPE_WID, **plugin_info))
+                    continue
                 # if WidgetSelector exists, it will be our default widget
                 self.default_wid = plugin_info
 
             # we want everything optional, so we use plugin file name
             # if actual name is not found
             if 'name' not in plugin_info:
-                plugin_info['name'] = plug[plug.rfind('_')+1:]
+                name_start = 8 + len(plugin_type)
+                plugin_info['name'] = plug[name_start:]
 
             # we need to load the kv file
             if 'kv_file' not in plugin_info:
@@ -406,7 +462,7 @@
             # factory is used to create the instance
             # if not found, we use a defaut one with getOrCreateWidget
             if 'factory' not in plugin_info:
-                plugin_info['factory'] = self._defaultFactory
+                plugin_info['factory'] = default_factory
 
             # icons
             for size in ('small', 'medium'):
@@ -421,38 +477,53 @@
                         path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir)
                 plugin_info[key] = path
 
-            self._plg_wids.append(plugin_info)
+            plugins_set.append(plugin_info)
         if not self._plg_wids:
             log.error(_(u"no widget plugin found"))
             return
 
         # we want widgets sorted by names
         self._plg_wids.sort(key=lambda p: p['name'].lower())
+        self._plg_wids_upload.sort(key=lambda p: p['name'].lower())
 
         if self.default_wid is None:
             # we have no selector widget, we use the first widget as default
             self.default_wid = self._plg_wids[0]
 
-    def getPluggedWidgets(self, except_cls=None):
+    def _getPluginsSet(self, type_):
+        if type_ == C.PLUG_TYPE_WID:
+            return self._plg_wids
+        elif type_ == C.PLUG_TYPE_UPLOAD:
+            return self._plg_wids_upload
+        else:
+            raise KeyError(u"{} plugin type is unknown".format(type_))
+
+    def getPluggedWidgets(self, type_=C.PLUG_TYPE_WID, except_cls=None):
         """get available widgets plugin infos
 
+        @param type_(unicode): type of widgets to get
+            one of C.PLUG_TYPE_* constant
         @param except_cls(None, class): if not None,
             widgets from this class will be excluded
         @return (iter[dict]): available widgets plugin infos
         """
-        for plugin_data in self._plg_wids:
+        plugins_set = self._getPluginsSet(type_)
+        for plugin_data in plugins_set:
             if plugin_data['main'] == except_cls:
                 continue
             yield plugin_data
 
-    def getPluginInfo(self, **kwargs):
+    def getPluginInfo(self, type_=C.PLUG_TYPE_WID, **kwargs):
         """get first plugin info corresponding to filters
 
+        @param type_(unicode): type of widgets to get
+            one of C.PLUG_TYPE_* constant
         @param **kwargs: filter(s) to use, each key present here must also
             exist and be of the same value in requested plugin info
         @return (dict, None): found plugin info or None
         """
-        for plugin_info in self._plg_wids:
+        plugins_set = self._getPluginsSet(type_)
+        for plugin_info in plugins_set:
             for k, w in kwargs.iteritems():
                 try:
                     if plugin_info[k] != w:
--- a/src/cagou/core/constants.py	Sat Dec 24 14:20:49 2016 +0100
+++ b/src/cagou/core/constants.py	Sun Dec 25 16:41:21 2016 +0100
@@ -21,9 +21,12 @@
 
 
 class Const(constants.Const):
-    APP_NAME = "Cagou"
+    APP_NAME = u"Cagou"
     LOG_OPT_SECTION = APP_NAME.lower()
     CONFIG_SECTION = APP_NAME.lower()
-    WID_SELECTOR = 'selector'
-    ICON_SIZES = ('small', 'medium')  # small = 32, medium = 44
+    WID_SELECTOR = u'selector'
+    ICON_SIZES = (u'small', u'medium')  # small = 32, medium = 44
     DEFAULT_WIDGET_ICON = u'{media}/misc/black.png'
+
+    PLUG_TYPE_WID = u'wid'
+    PLUG_TYPE_UPLOAD = u'upload'
--- a/src/cagou/core/menu.py	Sat Dec 24 14:20:49 2016 +0100
+++ b/src/cagou/core/menu.py	Sun Dec 25 16:41:21 2016 +0100
@@ -157,3 +157,51 @@
         about.title = ABOUT_TITLE
         about.content = AboutContent(text=ABOUT_CONTENT, markup=True)
         about.open()
+
+
+class UploadMenu(contextmenu.ContextMenu):
+    # callback will be called with path to file to upload
+    callback = properties.ObjectProperty()
+    # cancel callback need to remove the widget for UI
+    # will be called with the widget to remove as argument
+    cancel_cb = properties.ObjectProperty()
+    # profiles if set will be sent to upload widget, may be used to get specific files
+    profiles = properties.ObjectProperty()
+
+    def __init__(self, **kwargs):
+        super(UploadMenu, self).__init__(**kwargs)
+        if self.cancel_cb is None:
+            self.cancel_cb = self.onUploadCancelled
+        if self.profiles is None:
+            self.profiles = iter(G.host.profiles)
+        for plug_info in G.host.getPluggedWidgets(type_=C.PLUG_TYPE_UPLOAD):
+            item = contextmenu.ContextMenuTextItem(
+                text = plug_info['name'],
+                on_release = lambda dummy, plug_info=plug_info: self.do_callback(plug_info)
+                )
+            self.add_widget(item)
+
+    def show(self, caller_wid=None):
+        if caller_wid is not None:
+            pos = caller_wid.x, caller_wid.top + self.get_height()
+        else:
+            pos = G.host.app.root_window.mouse_pos
+        super(UploadMenu, self).show(*pos)
+
+    def _closeUI(self, wid):
+        G.host.closeUI()
+
+    def onUploadCancelled(self, wid):
+        self._closeUI(wid)
+
+    def do_callback(self, plug_info):
+        self.hide()
+        if self.callback is None:
+            log.warning(u"UploadMenu callback is not set")
+        else:
+            wid = None
+            def onUploadCb(file_path):
+                self._closeUI(wid)
+                self.callback(file_path)
+            wid = plug_info['factory'](plug_info, onUploadCb, self.cancel_cb, self.profiles)
+            G.host.showExtraUI(wid)
--- a/src/cagou/kv/menu.kv	Sat Dec 24 14:20:49 2016 +0100
+++ b/src/cagou/kv/menu.kv	Sun Dec 25 16:41:21 2016 +0100
@@ -33,3 +33,6 @@
 
 <MainMenu>:
     cancel_handler_widget: self.parent
+
+<UploadMenu>:
+    cancel_handler_widget: self.parent if self.parent else self.orig_parent
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/cagou/plugins/plugin_upload_file.py	Sun Dec 25 16:41:21 2016 +0100
@@ -0,0 +1,42 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
+# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero 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 Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from sat.core import log as logging
+log = logging.getLogger(__name__)
+from sat.core.i18n import _
+from kivy.uix.boxlayout import BoxLayout
+from kivy import properties
+
+
+PLUGIN_INFO = {
+    "name": _(u"file"),
+    "main": "FileUploader",
+    "description": _(u"upload a local file"),
+}
+
+
+class FileUploader(BoxLayout):
+    callback = properties.ObjectProperty()
+    cancel_cb = properties.ObjectProperty()
+
+    def onUploadOK(self, filechooser):
+        if filechooser.selection:
+            file_path = filechooser.selection[0]
+            self.callback(file_path)
--- a/src/cagou/plugins/plugin_wid_chat.kv	Sat Dec 24 14:20:49 2016 +0100
+++ b/src/cagou/plugins/plugin_wid_chat.kv	Sun Dec 25 16:41:21 2016 +0100
@@ -14,8 +14,6 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-#:import expanduser os.path.expanduser
-#:import platform kivy.utils.platform
 
 <SimpleXHTMLWidgetEscapedText>:
     size_hint: None, None
@@ -83,34 +81,31 @@
             padding: root.mess_padding
             bold: True if root.mess_data.type == "info" else False
 
-<MessageInputBox>:
-    size_hint: 1, None
-    height: dp(40)
-    message_input: message_input
-    MessageInputWidget:
-        id: message_input
-        size_hint: 1, 1
-        hint_text: "Enter your message here"
-        on_text_validate: root.parent.onSend(args[0])
-    IconButton
-        # upload button
-        source: app.expand("{media}/icons/tango/actions/32/list-add.png")
-        allow_stretch: True
-        size_hint: None, 1
-        width: max(self.texture_size[0], dp(40))
-        on_release: root.parent.onUploadButton()
 
-<FileUploader>:
-    FileChooserListView:
-        id: filechooser
-        rootpath: "/" if platform == 'android' else expanduser('~')
-    Button:
-        text: "upload"
+<Chat>:
+    messages_widget: messages_widget
+    ScrollView:
+        size_hint: 1, 0.8
+        scroll_y: 0
+        do_scroll_x: False
+        MessagesWidget:
+            id: messages_widget
+    MessageInputBox:
         size_hint: 1, None
-        height: dp(50)
-        on_release: root.parent_chat.onUploadOK(filechooser)
-    Button:
-        text: "cancel"
-        size_hint: 1, None
-        height: dp(50)
-        on_release: root.parent_chat.onUploadCancel(filechooser)
+        height: dp(40)
+        message_input: message_input
+        MessageInputWidget:
+            id: message_input
+            size_hint: 1, 1
+            hint_text: "Enter your message here"
+            on_text_validate: root.onSend(args[0])
+        IconButton
+            # upload button
+            source: app.expand("{media}/icons/tango/actions/32/list-add.png")
+            allow_stretch: True
+            size_hint: None, 1
+            width: max(self.texture_size[0], dp(40))
+            on_release: upload_menu.show(self)
+            UploadMenu:
+                id: upload_menu
+                callback: root.onUploadOK
--- a/src/cagou/plugins/plugin_wid_chat.py	Sat Dec 24 14:20:49 2016 +0100
+++ b/src/cagou/plugins/plugin_wid_chat.py	Sun Dec 25 16:41:21 2016 +0100
@@ -25,7 +25,6 @@
 from kivy.uix.boxlayout import BoxLayout
 from kivy.uix.gridlayout import GridLayout
 from kivy.uix.stacklayout import StackLayout
-from kivy.uix.scrollview import ScrollView
 from kivy.uix.textinput import TextInput
 from kivy.uix.label import Label
 from kivy.uix.image import AsyncImage
@@ -548,25 +547,14 @@
     pass
 
 
-class FileUploader(BoxLayout):
-
-    def __init__(self, parent_chat, **kwargs):
-        self.parent_chat = parent_chat
-        super(FileUploader, self).__init__(orientation='vertical', **kwargs)
-
-
 class Chat(quick_chat.QuickChat, cagou_widget.CagouWidget):
     message_input = properties.ObjectProperty()
+    messages_widget = properties.ObjectProperty()
 
     def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None):
         quick_chat.QuickChat.__init__(self, host, target, type_, nick, occupants, subject, profiles=profiles)
         cagou_widget.CagouWidget.__init__(self)
         self.header_input.hint_text = u"{}".format(target)
-        scroll_view = ScrollView(size_hint=(1,0.8), scroll_y=0, do_scroll_x=False)
-        self.messages_widget = MessagesWidget()
-        scroll_view.add_widget(self.messages_widget)
-        self.add_widget(scroll_view)
-        self.add_widget(MessageInputBox())
         self.host.addListener('progressError', self.onProgressError, profiles)
         self.host.addListener('progressFinished', self.onProgressFinished, profiles)
         self._waiting_pids = {}  # waiting progress ids
@@ -621,9 +609,6 @@
         # TODO: display message to user
         log.warning(u"Can't upload file: {}".format(err_msg))
 
-    def onUploadButton(self):
-        G.host.showExtraUI(FileUploader(self))
-
     def fileUploadDone(self, metadata, profile):
         log.debug("file uploaded: {}".format(metadata))
         G.host.messageSend(
@@ -642,21 +627,15 @@
         else:
             self._waiting_pids[progress_id] = self.fileUploadDone
 
-    def onUploadOK(self, file_chooser):
-        if file_chooser.selection:
-            file_path = file_chooser.selection[0]
-            G.host.bridge.fileUpload(
-                file_path,
-                "",
-                "",
-                {"ignore_tls_errors": C.BOOL_TRUE},  # FIXME: should not be the default
-                self.profile,
-                callback = self.fileUploadCb
-                )
-        G.host.closeUI()
-
-    def onUploadCancel(self, file_chooser):
-        G.host.closeUI()
+    def onUploadOK(self, file_path):
+        G.host.bridge.fileUpload(
+            file_path,
+            "",
+            "",
+            {"ignore_tls_errors": C.BOOL_TRUE},  # FIXME: should not be the default
+            self.profile,
+            callback = self.fileUploadCb
+            )
 
     def _mucJoinCb(self, joined_data):
         joined, room_jid_s, occupants, user_nick, subject, profile = joined_data