changeset 4074:26b7ed2817da

refactoring: rename `sat_frontends` to `libervia.frontends`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 14:12:38 +0200
parents 7c5654c54fed
children 47401850dec6
files libervia/backend/bridge/bridge_constructor/constructors/pb/pb_frontend_template.py libervia/backend/plugins/plugin_misc_tarot.py libervia/backend/plugins/plugin_misc_text_syntaxes.py libervia/backend/tools/common/template_xmlui.py libervia/frontends/__init__.py libervia/frontends/bridge/__init__.py libervia/frontends/bridge/bridge_frontend.py libervia/frontends/bridge/dbus_bridge.py libervia/frontends/bridge/pb.py libervia/frontends/jp/__init__.py libervia/frontends/jp/arg_tools.py libervia/frontends/jp/base.py libervia/frontends/jp/cmd_account.py libervia/frontends/jp/cmd_adhoc.py libervia/frontends/jp/cmd_application.py libervia/frontends/jp/cmd_avatar.py libervia/frontends/jp/cmd_blocking.py libervia/frontends/jp/cmd_blog.py libervia/frontends/jp/cmd_bookmarks.py libervia/frontends/jp/cmd_debug.py libervia/frontends/jp/cmd_encryption.py libervia/frontends/jp/cmd_event.py libervia/frontends/jp/cmd_file.py libervia/frontends/jp/cmd_forums.py libervia/frontends/jp/cmd_identity.py libervia/frontends/jp/cmd_info.py libervia/frontends/jp/cmd_input.py libervia/frontends/jp/cmd_invitation.py libervia/frontends/jp/cmd_list.py libervia/frontends/jp/cmd_merge_request.py libervia/frontends/jp/cmd_message.py libervia/frontends/jp/cmd_param.py libervia/frontends/jp/cmd_ping.py libervia/frontends/jp/cmd_pipe.py libervia/frontends/jp/cmd_profile.py libervia/frontends/jp/cmd_pubsub.py libervia/frontends/jp/cmd_roster.py libervia/frontends/jp/cmd_shell.py libervia/frontends/jp/cmd_uri.py libervia/frontends/jp/common.py libervia/frontends/jp/constants.py libervia/frontends/jp/loops.py libervia/frontends/jp/output_std.py libervia/frontends/jp/output_template.py libervia/frontends/jp/output_xml.py libervia/frontends/jp/output_xmlui.py libervia/frontends/jp/xml_tools.py libervia/frontends/jp/xmlui_manager.py libervia/frontends/primitivus/__init__.py libervia/frontends/primitivus/base.py libervia/frontends/primitivus/chat.py libervia/frontends/primitivus/config.py libervia/frontends/primitivus/constants.py libervia/frontends/primitivus/contact_list.py libervia/frontends/primitivus/game_tarot.py libervia/frontends/primitivus/keys.py libervia/frontends/primitivus/notify.py libervia/frontends/primitivus/profile_manager.py libervia/frontends/primitivus/progress.py libervia/frontends/primitivus/status.py libervia/frontends/primitivus/widget.py libervia/frontends/primitivus/xmlui.py libervia/frontends/quick_frontend/__init__.py libervia/frontends/quick_frontend/constants.py libervia/frontends/quick_frontend/quick_app.py libervia/frontends/quick_frontend/quick_blog.py libervia/frontends/quick_frontend/quick_chat.py libervia/frontends/quick_frontend/quick_contact_list.py libervia/frontends/quick_frontend/quick_contact_management.py libervia/frontends/quick_frontend/quick_game_tarot.py libervia/frontends/quick_frontend/quick_games.py libervia/frontends/quick_frontend/quick_list_manager.py libervia/frontends/quick_frontend/quick_menus.py libervia/frontends/quick_frontend/quick_profile_manager.py libervia/frontends/quick_frontend/quick_utils.py libervia/frontends/quick_frontend/quick_widgets.py libervia/frontends/tools/__init__.py libervia/frontends/tools/composition.py libervia/frontends/tools/css_color.py libervia/frontends/tools/games.py libervia/frontends/tools/host_listener.py libervia/frontends/tools/jid.py libervia/frontends/tools/misc.py libervia/frontends/tools/strings.py libervia/frontends/tools/xmltools.py libervia/frontends/tools/xmlui.py sat_frontends/__init__.py sat_frontends/bridge/__init__.py sat_frontends/bridge/bridge_frontend.py sat_frontends/bridge/dbus_bridge.py sat_frontends/bridge/pb.py sat_frontends/jp/__init__.py sat_frontends/jp/arg_tools.py sat_frontends/jp/base.py sat_frontends/jp/cmd_account.py sat_frontends/jp/cmd_adhoc.py sat_frontends/jp/cmd_application.py sat_frontends/jp/cmd_avatar.py sat_frontends/jp/cmd_blocking.py sat_frontends/jp/cmd_blog.py sat_frontends/jp/cmd_bookmarks.py sat_frontends/jp/cmd_debug.py sat_frontends/jp/cmd_encryption.py sat_frontends/jp/cmd_event.py sat_frontends/jp/cmd_file.py sat_frontends/jp/cmd_forums.py sat_frontends/jp/cmd_identity.py sat_frontends/jp/cmd_info.py sat_frontends/jp/cmd_input.py sat_frontends/jp/cmd_invitation.py sat_frontends/jp/cmd_list.py sat_frontends/jp/cmd_merge_request.py sat_frontends/jp/cmd_message.py sat_frontends/jp/cmd_param.py sat_frontends/jp/cmd_ping.py sat_frontends/jp/cmd_pipe.py sat_frontends/jp/cmd_profile.py sat_frontends/jp/cmd_pubsub.py sat_frontends/jp/cmd_roster.py sat_frontends/jp/cmd_shell.py sat_frontends/jp/cmd_uri.py sat_frontends/jp/common.py sat_frontends/jp/constants.py sat_frontends/jp/loops.py sat_frontends/jp/output_std.py sat_frontends/jp/output_template.py sat_frontends/jp/output_xml.py sat_frontends/jp/output_xmlui.py sat_frontends/jp/xml_tools.py sat_frontends/jp/xmlui_manager.py sat_frontends/primitivus/__init__.py sat_frontends/primitivus/base.py sat_frontends/primitivus/chat.py sat_frontends/primitivus/config.py sat_frontends/primitivus/constants.py sat_frontends/primitivus/contact_list.py sat_frontends/primitivus/game_tarot.py sat_frontends/primitivus/keys.py sat_frontends/primitivus/notify.py sat_frontends/primitivus/profile_manager.py sat_frontends/primitivus/progress.py sat_frontends/primitivus/status.py sat_frontends/primitivus/widget.py sat_frontends/primitivus/xmlui.py sat_frontends/quick_frontend/__init__.py sat_frontends/quick_frontend/constants.py sat_frontends/quick_frontend/quick_app.py sat_frontends/quick_frontend/quick_blog.py sat_frontends/quick_frontend/quick_chat.py sat_frontends/quick_frontend/quick_contact_list.py sat_frontends/quick_frontend/quick_contact_management.py sat_frontends/quick_frontend/quick_game_tarot.py sat_frontends/quick_frontend/quick_games.py sat_frontends/quick_frontend/quick_list_manager.py sat_frontends/quick_frontend/quick_menus.py sat_frontends/quick_frontend/quick_profile_manager.py sat_frontends/quick_frontend/quick_utils.py sat_frontends/quick_frontend/quick_widgets.py sat_frontends/tools/__init__.py sat_frontends/tools/composition.py sat_frontends/tools/css_color.py sat_frontends/tools/games.py sat_frontends/tools/host_listener.py sat_frontends/tools/jid.py sat_frontends/tools/misc.py sat_frontends/tools/strings.py sat_frontends/tools/xmltools.py sat_frontends/tools/xmlui.py setup.py
diffstat 157 files changed, 29370 insertions(+), 29370 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/backend/bridge/bridge_constructor/constructors/pb/pb_frontend_template.py	Fri Jun 02 12:59:21 2023 +0200
+++ b/libervia/backend/bridge/bridge_constructor/constructors/pb/pb_frontend_template.py	Fri Jun 02 14:12:38 2023 +0200
@@ -25,7 +25,7 @@
 from twisted.internet.error import ConnectionRefusedError, ConnectError
 from libervia.backend.core import exceptions
 from libervia.backend.tools import config
-from sat_frontends.bridge.bridge_frontend import BridgeException
+from libervia.frontends.bridge.bridge_frontend import BridgeException
 
 log = getLogger(__name__)
 
--- a/libervia/backend/plugins/plugin_misc_tarot.py	Fri Jun 02 12:59:21 2023 +0200
+++ b/libervia/backend/plugins/plugin_misc_tarot.py	Fri Jun 02 14:12:38 2023 +0200
@@ -29,7 +29,7 @@
 
 from libervia.backend.memory import memory
 from libervia.backend.tools import xml_tools
-from sat_frontends.tools.games import TarotCard
+from libervia.frontends.tools.games import TarotCard
 import random
 
 
--- a/libervia/backend/plugins/plugin_misc_text_syntaxes.py	Fri Jun 02 12:59:21 2023 +0200
+++ b/libervia/backend/plugins/plugin_misc_text_syntaxes.py	Fri Jun 02 14:12:38 2023 +0200
@@ -203,7 +203,7 @@
             TextSyntaxes.OPT_NO_THREAD,
         )
         # TODO: text => XHTML should add <a/> to url like in frontends
-        #       it's probably best to move sat_frontends.tools.strings to sat.tools.common or similar
+        #       it's probably best to move libervia.frontends.tools.strings to sat.tools.common or similar
         self.add_syntax(
             self.SYNTAX_TEXT,
             lambda text: escape(text),
--- a/libervia/backend/tools/common/template_xmlui.py	Fri Jun 02 12:59:21 2023 +0200
+++ b/libervia/backend/tools/common/template_xmlui.py	Fri Jun 02 14:12:38 2023 +0200
@@ -24,8 +24,8 @@
 
 from functools import partial
 from libervia.backend.core.log import getLogger
-from sat_frontends.tools import xmlui
-from sat_frontends.tools import jid
+from libervia.frontends.tools import xmlui
+from libervia.frontends.tools import jid
 try:
     from jinja2 import Markup as safe
 except ImportError:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/bridge/bridge_frontend.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+
+
+# SAT communication bridge
+# Copyright (C) 2009-2021 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/>.
+
+
+class BridgeException(Exception):
+    """An exception which has been raised from the backend and arrived to the frontend."""
+
+    def __init__(self, name, message="", condition=""):
+        """
+
+        @param name (str): full exception class name (with module)
+        @param message (str): error message
+        @param condition (str) : error condition
+        """
+        super().__init__()
+        self.fullname = str(name)
+        self.message = str(message)
+        self.condition = str(condition) if condition else ""
+        self.module, __, self.classname = str(self.fullname).rpartition(".")
+
+    def __str__(self):
+        return self.classname + (f": {self.message}" if self.message else "")
+
+    def __eq__(self, other):
+        return self.classname == other
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/bridge/dbus_bridge.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1512 @@
+#!/usr/bin/env python3
+
+# SàT communication bridge
+# Copyright (C) 2009-2021 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/>.
+
+import asyncio
+import dbus
+import ast
+from libervia.backend.core.i18n import _
+from libervia.backend.tools import config
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.exceptions import BridgeExceptionNoService, BridgeInitError
+from dbus.mainloop.glib import DBusGMainLoop
+from .bridge_frontend import BridgeException
+
+
+DBusGMainLoop(set_as_default=True)
+log = getLogger(__name__)
+
+
+# Interface prefix
+const_INT_PREFIX = config.config_get(
+    config.parse_main_conf(),
+    "",
+    "bridge_dbus_int_prefix",
+    "org.libervia.Libervia")
+const_ERROR_PREFIX = const_INT_PREFIX + ".error"
+const_OBJ_PATH = '/org/libervia/Libervia/bridge'
+const_CORE_SUFFIX = ".core"
+const_PLUGIN_SUFFIX = ".plugin"
+const_TIMEOUT = 120
+
+
+def dbus_to_bridge_exception(dbus_e):
+    """Convert a DBusException to a BridgeException.
+
+    @param dbus_e (DBusException)
+    @return: BridgeException
+    """
+    full_name = dbus_e.get_dbus_name()
+    if full_name.startswith(const_ERROR_PREFIX):
+        name = dbus_e.get_dbus_name()[len(const_ERROR_PREFIX) + 1:]
+    else:
+        name = full_name
+    # XXX: dbus_e.args doesn't contain the original DBusException args, but we
+    # receive its serialized form in dbus_e.args[0]. From that we can rebuild
+    # the original arguments list thanks to ast.literal_eval (secure eval).
+    message = dbus_e.get_dbus_message()  # similar to dbus_e.args[0]
+    try:
+        message, condition = ast.literal_eval(message)
+    except (SyntaxError, ValueError, TypeError):
+        condition = ''
+    return BridgeException(name, message, condition)
+
+
+class bridge:
+
+    def bridge_connect(self, callback, errback):
+        try:
+            self.sessions_bus = dbus.SessionBus()
+            self.db_object = self.sessions_bus.get_object(const_INT_PREFIX,
+                                                          const_OBJ_PATH)
+            self.db_core_iface = dbus.Interface(self.db_object,
+                                                dbus_interface=const_INT_PREFIX + const_CORE_SUFFIX)
+            self.db_plugin_iface = dbus.Interface(self.db_object,
+                                                  dbus_interface=const_INT_PREFIX + const_PLUGIN_SUFFIX)
+        except dbus.exceptions.DBusException as e:
+            if e._dbus_error_name in ('org.freedesktop.DBus.Error.ServiceUnknown',
+                                      'org.freedesktop.DBus.Error.Spawn.ExecFailed'):
+                errback(BridgeExceptionNoService())
+            elif e._dbus_error_name == 'org.freedesktop.DBus.Error.NotSupported':
+                log.error(_("D-Bus is not launched, please see README to see instructions on how to launch it"))
+                errback(BridgeInitError)
+            else:
+                errback(e)
+        else:
+            callback()
+        #props = self.db_core_iface.getProperties()
+
+    def register_signal(self, functionName, handler, iface="core"):
+        if iface == "core":
+            self.db_core_iface.connect_to_signal(functionName, handler)
+        elif iface == "plugin":
+            self.db_plugin_iface.connect_to_signal(functionName, handler)
+        else:
+            log.error(_('Unknown interface'))
+
+    def __getattribute__(self, name):
+        """ usual __getattribute__ if the method exists, else try to find a plugin method """
+        try:
+            return object.__getattribute__(self, name)
+        except AttributeError:
+            # The attribute is not found, we try the plugin proxy to find the requested method
+
+            def get_plugin_method(*args, **kwargs):
+                # We first check if we have an async call. We detect this in two ways:
+                #   - if we have the 'callback' and 'errback' keyword arguments
+                #   - or if the last two arguments are callable
+
+                async_ = False
+                args = list(args)
+
+                if kwargs:
+                    if 'callback' in kwargs:
+                        async_ = True
+                        _callback = kwargs.pop('callback')
+                        _errback = kwargs.pop('errback', lambda failure: log.error(str(failure)))
+                    try:
+                        args.append(kwargs.pop('profile'))
+                    except KeyError:
+                        try:
+                            args.append(kwargs.pop('profile_key'))
+                        except KeyError:
+                            pass
+                    # at this point, kwargs should be empty
+                    if kwargs:
+                        log.warning("unexpected keyword arguments, they will be ignored: {}".format(kwargs))
+                elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]):
+                    async_ = True
+                    _errback = args.pop()
+                    _callback = args.pop()
+
+                method = getattr(self.db_plugin_iface, name)
+
+                if async_:
+                    kwargs['timeout'] = const_TIMEOUT
+                    kwargs['reply_handler'] = _callback
+                    kwargs['error_handler'] = lambda err: _errback(dbus_to_bridge_exception(err))
+
+                try:
+                    return method(*args, **kwargs)
+                except ValueError as e:
+                    if e.args[0].startswith("Unable to guess signature"):
+                        # XXX: if frontend is started too soon after backend, the
+                        #   inspection misses methods (notably plugin dynamically added
+                        #   methods). The following hack works around that by redoing the
+                        #   cache of introspected methods signatures.
+                        log.debug("using hack to work around inspection issue")
+                        proxy = self.db_plugin_iface.proxy_object
+                        IN_PROGRESS = proxy.INTROSPECT_STATE_INTROSPECT_IN_PROGRESS
+                        proxy._introspect_state = IN_PROGRESS
+                        proxy._Introspect()
+                        return self.db_plugin_iface.get_dbus_method(name)(*args, **kwargs)
+                    raise e
+
+            return get_plugin_method
+
+    def action_launch(self, callback_id, data, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return str(self.db_core_iface.action_launch(callback_id, data, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
+
+    def actions_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.actions_get(profile_key, **kwargs)
+
+    def config_get(self, section, name, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.config_get(section, name, **kwargs))
+
+    def connect(self, profile_key="@DEFAULT@", password='', options={}, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.connect(profile_key, password, options, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def contact_add(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.contact_add(entity_jid, profile_key, **kwargs)
+
+    def contact_del(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.contact_del(entity_jid, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def contact_get(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.contact_get(arg_0, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.contact_update(entity_jid, name, groups, profile_key, **kwargs)
+
+    def contacts_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.contacts_get(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def contacts_get_from_group(self, group, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.contacts_get_from_group(group, profile_key, **kwargs)
+
+    def devices_infos_get(self, bare_jid, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return str(self.db_core_iface.devices_infos_get(bare_jid, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
+
+    def disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.disco_find_by_features(namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.disco_infos(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.disco_items(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def disconnect(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.disconnect(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def encryption_namespace_get(self, arg_0, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.encryption_namespace_get(arg_0, **kwargs))
+
+    def encryption_plugins_get(self, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.encryption_plugins_get(**kwargs))
+
+    def encryption_trust_ui_get(self, to_jid, namespace, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return str(self.db_core_iface.encryption_trust_ui_get(to_jid, namespace, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
+
+    def entities_data_get(self, jids, keys, profile, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.entities_data_get(jids, keys, profile, **kwargs)
+
+    def entity_data_get(self, jid, keys, profile, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.entity_data_get(jid, keys, profile, **kwargs)
+
+    def features_get(self, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.features_get(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.history_get(from_jid, to_jid, limit, between, filters, profile, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def image_check(self, arg_0, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.image_check(arg_0, **kwargs))
+
+    def image_convert(self, source, dest, arg_2, extra, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return str(self.db_core_iface.image_convert(source, dest, arg_2, extra, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
+
+    def image_generate_preview(self, image_path, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return str(self.db_core_iface.image_generate_preview(image_path, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
+
+    def image_resize(self, image_path, width, height, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return str(self.db_core_iface.image_resize(image_path, width, height, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
+
+    def is_connected(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.is_connected(profile_key, **kwargs)
+
+    def main_resource_get(self, contact_jid, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.main_resource_get(contact_jid, profile_key, **kwargs))
+
+    def menu_help_get(self, menu_id, language, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.menu_help_get(menu_id, language, **kwargs))
+
+    def menu_launch(self, menu_type, path, data, security_limit, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.menu_launch(menu_type, path, data, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def menus_get(self, language, security_limit, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.menus_get(language, security_limit, **kwargs)
+
+    def message_encryption_get(self, to_jid, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.message_encryption_get(to_jid, profile_key, **kwargs))
+
+    def message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.message_encryption_start(to_jid, namespace, replace, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def message_encryption_stop(self, to_jid, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.message_encryption_stop(to_jid, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.message_send(to_jid, message, subject, mess_type, extra, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def namespaces_get(self, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.namespaces_get(**kwargs)
+
+    def param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.param_get_a(name, category, attribute, profile_key, **kwargs))
+
+    def param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return str(self.db_core_iface.param_get_a_async(name, category, attribute, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
+
+    def param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.param_set(name, value, category, security_limit, profile_key, **kwargs)
+
+    def param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return str(self.db_core_iface.param_ui_get(security_limit, app, extra, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
+
+    def params_categories_get(self, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.params_categories_get(**kwargs)
+
+    def params_register_app(self, xml, security_limit=-1, app='', callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.params_register_app(xml, security_limit, app, **kwargs)
+
+    def params_template_load(self, filename, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.params_template_load(filename, **kwargs)
+
+    def params_template_save(self, filename, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.params_template_save(filename, **kwargs)
+
+    def params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.params_values_from_category_get_async(category, security_limit, app, extra, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.presence_set(to_jid, show, statuses, profile_key, **kwargs)
+
+    def presence_statuses_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.presence_statuses_get(profile_key, **kwargs)
+
+    def private_data_delete(self, namespace, key, arg_2, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.private_data_delete(namespace, key, arg_2, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def private_data_get(self, namespace, key, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return str(self.db_core_iface.private_data_get(namespace, key, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
+
+    def private_data_set(self, namespace, key, data, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.private_data_set(namespace, key, data, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def profile_create(self, profile, password='', component='', callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.profile_create(profile, password, component, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def profile_delete_async(self, profile, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.profile_delete_async(profile, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def profile_is_session_started(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.profile_is_session_started(profile_key, **kwargs)
+
+    def profile_name_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.profile_name_get(profile_key, **kwargs))
+
+    def profile_set_default(self, profile, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.profile_set_default(profile, **kwargs)
+
+    def profile_start_session(self, password='', profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.profile_start_session(password, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def profiles_list_get(self, clients=True, components=False, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.profiles_list_get(clients, components, **kwargs)
+
+    def progress_get(self, id, profile, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.progress_get(id, profile, **kwargs)
+
+    def progress_get_all(self, profile, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.progress_get_all(profile, **kwargs)
+
+    def progress_get_all_metadata(self, profile, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.progress_get_all_metadata(profile, **kwargs)
+
+    def ready_get(self, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.ready_get(timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def roster_resync(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.roster_resync(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def session_infos_get(self, profile_key, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        return self.db_core_iface.session_infos_get(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
+
+    def sub_waiting_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.sub_waiting_get(profile_key, **kwargs)
+
+    def subscription(self, sub_type, entity, profile_key="@DEFAULT@", callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return self.db_core_iface.subscription(sub_type, entity, profile_key, **kwargs)
+
+    def version_get(self, callback=None, errback=None):
+        if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        return str(self.db_core_iface.version_get(**kwargs))
+
+
+class AIOBridge(bridge):
+
+    def register_signal(self, functionName, handler, iface="core"):
+        loop = asyncio.get_running_loop()
+        async_handler = lambda *args: asyncio.run_coroutine_threadsafe(handler(*args), loop)
+        return super().register_signal(functionName, async_handler, iface)
+
+    def __getattribute__(self, name):
+        """ usual __getattribute__ if the method exists, else try to find a plugin method """
+        try:
+            return object.__getattribute__(self, name)
+        except AttributeError:
+            # The attribute is not found, we try the plugin proxy to find the requested method
+            def get_plugin_method(*args, **kwargs):
+                loop = asyncio.get_running_loop()
+                fut = loop.create_future()
+                method = getattr(self.db_plugin_iface, name)
+                reply_handler = lambda ret=None: loop.call_soon_threadsafe(
+                    fut.set_result, ret)
+                error_handler = lambda err: loop.call_soon_threadsafe(
+                    fut.set_exception, dbus_to_bridge_exception(err))
+                try:
+                    method(
+                        *args,
+                        **kwargs,
+                        timeout=const_TIMEOUT,
+                        reply_handler=reply_handler,
+                        error_handler=error_handler
+                    )
+                except ValueError as e:
+                    if e.args[0].startswith("Unable to guess signature"):
+                        # same hack as for bridge.__getattribute__
+                        log.warning("using hack to work around inspection issue")
+                        proxy = self.db_plugin_iface.proxy_object
+                        IN_PROGRESS = proxy.INTROSPECT_STATE_INTROSPECT_IN_PROGRESS
+                        proxy._introspect_state = IN_PROGRESS
+                        proxy._Introspect()
+                        self.db_plugin_iface.get_dbus_method(name)(
+                            *args,
+                            **kwargs,
+                            timeout=const_TIMEOUT,
+                            reply_handler=reply_handler,
+                            error_handler=error_handler
+                        )
+
+                    else:
+                        raise e
+                return fut
+
+            return get_plugin_method
+
+    def bridge_connect(self):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        super().bridge_connect(
+            callback=lambda: loop.call_soon_threadsafe(fut.set_result, None),
+            errback=lambda e: loop.call_soon_threadsafe(fut.set_exception, e)
+        )
+        return fut
+
+    def action_launch(self, callback_id, data, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.action_launch(callback_id, data, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def actions_get(self, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.actions_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def config_get(self, section, name):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.config_get(section, name, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def connect(self, profile_key="@DEFAULT@", password='', options={}):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.connect(profile_key, password, options, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def contact_add(self, entity_jid, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.contact_add(entity_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def contact_del(self, entity_jid, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.contact_del(entity_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def contact_get(self, arg_0, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.contact_get(arg_0, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.contact_update(entity_jid, name, groups, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def contacts_get(self, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.contacts_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def contacts_get_from_group(self, group, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.contacts_get_from_group(group, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def devices_infos_get(self, bare_jid, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.devices_infos_get(bare_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.disco_find_by_features(namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.disco_infos(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.disco_items(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def disconnect(self, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.disconnect(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def encryption_namespace_get(self, arg_0):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.encryption_namespace_get(arg_0, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def encryption_plugins_get(self):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.encryption_plugins_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def encryption_trust_ui_get(self, to_jid, namespace, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.encryption_trust_ui_get(to_jid, namespace, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def entities_data_get(self, jids, keys, profile):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.entities_data_get(jids, keys, profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def entity_data_get(self, jid, keys, profile):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.entity_data_get(jid, keys, profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def features_get(self, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.features_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.history_get(from_jid, to_jid, limit, between, filters, profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def image_check(self, arg_0):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.image_check(arg_0, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def image_convert(self, source, dest, arg_2, extra):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.image_convert(source, dest, arg_2, extra, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def image_generate_preview(self, image_path, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.image_generate_preview(image_path, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def image_resize(self, image_path, width, height):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.image_resize(image_path, width, height, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def is_connected(self, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.is_connected(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def main_resource_get(self, contact_jid, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.main_resource_get(contact_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def menu_help_get(self, menu_id, language):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.menu_help_get(menu_id, language, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def menu_launch(self, menu_type, path, data, security_limit, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.menu_launch(menu_type, path, data, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def menus_get(self, language, security_limit):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.menus_get(language, security_limit, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def message_encryption_get(self, to_jid, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.message_encryption_get(to_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.message_encryption_start(to_jid, namespace, replace, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def message_encryption_stop(self, to_jid, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.message_encryption_stop(to_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.message_send(to_jid, message, subject, mess_type, extra, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def namespaces_get(self):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.namespaces_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.param_get_a(name, category, attribute, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.param_get_a_async(name, category, attribute, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.param_set(name, value, category, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.param_ui_get(security_limit, app, extra, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def params_categories_get(self):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.params_categories_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def params_register_app(self, xml, security_limit=-1, app=''):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.params_register_app(xml, security_limit, app, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def params_template_load(self, filename):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.params_template_load(filename, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def params_template_save(self, filename):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.params_template_save(filename, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.params_values_from_category_get_async(category, security_limit, app, extra, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.presence_set(to_jid, show, statuses, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def presence_statuses_get(self, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.presence_statuses_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def private_data_delete(self, namespace, key, arg_2):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.private_data_delete(namespace, key, arg_2, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def private_data_get(self, namespace, key, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.private_data_get(namespace, key, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def private_data_set(self, namespace, key, data, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.private_data_set(namespace, key, data, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def profile_create(self, profile, password='', component=''):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.profile_create(profile, password, component, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def profile_delete_async(self, profile):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.profile_delete_async(profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def profile_is_session_started(self, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.profile_is_session_started(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def profile_name_get(self, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.profile_name_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def profile_set_default(self, profile):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.profile_set_default(profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def profile_start_session(self, password='', profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.profile_start_session(password, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def profiles_list_get(self, clients=True, components=False):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.profiles_list_get(clients, components, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def progress_get(self, id, profile):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.progress_get(id, profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def progress_get_all(self, profile):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.progress_get_all(profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def progress_get_all_metadata(self, profile):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.progress_get_all_metadata(profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def ready_get(self):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.ready_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def roster_resync(self, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.roster_resync(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def session_infos_get(self, profile_key):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.session_infos_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def sub_waiting_get(self, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.sub_waiting_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def subscription(self, sub_type, entity, profile_key="@DEFAULT@"):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.subscription(sub_type, entity, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
+
+    def version_get(self):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_core_iface.version_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        return fut
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/bridge/pb.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1120 @@
+#!/usr/bin/env python3
+
+# SàT communication bridge
+# Copyright (C) 2009-2021 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/>.
+
+import asyncio
+from logging import getLogger
+from functools import partial
+from pathlib import Path
+from twisted.spread import pb
+from twisted.internet import reactor, defer
+from twisted.internet.error import ConnectionRefusedError, ConnectError
+from libervia.backend.core import exceptions
+from libervia.backend.tools import config
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+
+log = getLogger(__name__)
+
+
+class SignalsHandler(pb.Referenceable):
+    def __getattr__(self, name):
+        if name.startswith("remote_"):
+            log.debug("calling an unregistered signal: {name}".format(name=name[7:]))
+            return lambda *args, **kwargs: None
+
+        else:
+            raise AttributeError(name)
+
+    def register_signal(self, name, handler, iface="core"):
+        log.debug("registering signal {name}".format(name=name))
+        method_name = "remote_" + name
+        try:
+            self.__getattribute__(method_name)
+        except AttributeError:
+            pass
+        else:
+            raise exceptions.InternalError(
+                "{name} signal handler has been registered twice".format(
+                    name=method_name
+                )
+            )
+        setattr(self, method_name, handler)
+
+
+class bridge(object):
+
+    def __init__(self):
+        self.signals_handler = SignalsHandler()
+
+    def __getattr__(self, name):
+        return partial(self.call, name)
+
+    def _generic_errback(self, err):
+        log.error(f"bridge error: {err}")
+
+    def _errback(self, failure_, ori_errback):
+        """Convert Failure to BridgeException"""
+        ori_errback(
+            BridgeException(
+                name=failure_.type.decode('utf-8'),
+                message=str(failure_.value)
+            )
+        )
+
+    def remote_callback(self, result, callback):
+        """call callback with argument or None
+
+        if result is not None not argument is used,
+        else result is used as argument
+        @param result: remote call result
+        @param callback(callable): method to call on result
+        """
+        if result is None:
+            callback()
+        else:
+            callback(result)
+
+    def call(self, name, *args, **kwargs):
+        """call a remote method
+
+        @param name(str): name of the bridge method
+        @param args(list): arguments
+            may contain callback and errback as last 2 items
+        @param kwargs(dict): keyword arguments
+            may contain callback and errback
+        """
+        callback = errback = None
+        if kwargs:
+            try:
+                callback = kwargs.pop("callback")
+            except KeyError:
+                pass
+            try:
+                errback = kwargs.pop("errback")
+            except KeyError:
+                pass
+        elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]):
+            errback = args.pop()
+            callback = args.pop()
+        d = self.root.callRemote(name, *args, **kwargs)
+        if callback is not None:
+            d.addCallback(self.remote_callback, callback)
+        if errback is not None:
+            d.addErrback(errback)
+
+    def _init_bridge_eb(self, failure_):
+        log.error("Can't init bridge: {msg}".format(msg=failure_))
+        return failure_
+
+    def _set_root(self, root):
+        """set remote root object
+
+        bridge will then be initialised
+        """
+        self.root = root
+        d = root.callRemote("initBridge", self.signals_handler)
+        d.addErrback(self._init_bridge_eb)
+        return d
+
+    def get_root_object_eb(self, failure_):
+        """Call errback with appropriate bridge error"""
+        if failure_.check(ConnectionRefusedError, ConnectError):
+            raise exceptions.BridgeExceptionNoService
+        else:
+            raise failure_
+
+    def bridge_connect(self, callback, errback):
+        factory = pb.PBClientFactory()
+        conf = config.parse_main_conf()
+        get_conf = partial(config.get_conf, conf, "bridge_pb", "")
+        conn_type = get_conf("connection_type", "unix_socket")
+        if conn_type == "unix_socket":
+            local_dir = Path(config.config_get(conf, "", "local_dir")).resolve()
+            socket_path = local_dir / "bridge_pb"
+            reactor.connectUNIX(str(socket_path), factory)
+        elif conn_type == "socket":
+            host = get_conf("host", "localhost")
+            port = int(get_conf("port", 8789))
+            reactor.connectTCP(host, port, factory)
+        else:
+            raise ValueError(f"Unknown pb connection type: {conn_type!r}")
+        d = factory.getRootObject()
+        d.addCallback(self._set_root)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        d.addErrback(self.get_root_object_eb)
+        if errback is not None:
+            d.addErrback(lambda failure_: errback(failure_.value))
+        return d
+
+    def register_signal(self, functionName, handler, iface="core"):
+        self.signals_handler.register_signal(functionName, handler, iface)
+
+
+    def action_launch(self, callback_id, data, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("action_launch", callback_id, data, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def actions_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("actions_get", profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def config_get(self, section, name, callback=None, errback=None):
+        d = self.root.callRemote("config_get", section, name)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def connect(self, profile_key="@DEFAULT@", password='', options={}, callback=None, errback=None):
+        d = self.root.callRemote("connect", profile_key, password, options)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def contact_add(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("contact_add", entity_jid, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def contact_del(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("contact_del", entity_jid, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def contact_get(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("contact_get", arg_0, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("contact_update", entity_jid, name, groups, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def contacts_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("contacts_get", profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def contacts_get_from_group(self, group, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("contacts_get_from_group", group, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def devices_infos_get(self, bare_jid, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("devices_infos_get", bare_jid, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("disco_find_by_features", namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("disco_infos", entity_jid, node, use_cache, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("disco_items", entity_jid, node, use_cache, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def disconnect(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("disconnect", profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def encryption_namespace_get(self, arg_0, callback=None, errback=None):
+        d = self.root.callRemote("encryption_namespace_get", arg_0)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def encryption_plugins_get(self, callback=None, errback=None):
+        d = self.root.callRemote("encryption_plugins_get")
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def encryption_trust_ui_get(self, to_jid, namespace, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("encryption_trust_ui_get", to_jid, namespace, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def entities_data_get(self, jids, keys, profile, callback=None, errback=None):
+        d = self.root.callRemote("entities_data_get", jids, keys, profile)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def entity_data_get(self, jid, keys, profile, callback=None, errback=None):
+        d = self.root.callRemote("entity_data_get", jid, keys, profile)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def features_get(self, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("features_get", profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@", callback=None, errback=None):
+        d = self.root.callRemote("history_get", from_jid, to_jid, limit, between, filters, profile)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def image_check(self, arg_0, callback=None, errback=None):
+        d = self.root.callRemote("image_check", arg_0)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def image_convert(self, source, dest, arg_2, extra, callback=None, errback=None):
+        d = self.root.callRemote("image_convert", source, dest, arg_2, extra)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def image_generate_preview(self, image_path, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("image_generate_preview", image_path, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def image_resize(self, image_path, width, height, callback=None, errback=None):
+        d = self.root.callRemote("image_resize", image_path, width, height)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def is_connected(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("is_connected", profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def main_resource_get(self, contact_jid, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("main_resource_get", contact_jid, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def menu_help_get(self, menu_id, language, callback=None, errback=None):
+        d = self.root.callRemote("menu_help_get", menu_id, language)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def menu_launch(self, menu_type, path, data, security_limit, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("menu_launch", menu_type, path, data, security_limit, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def menus_get(self, language, security_limit, callback=None, errback=None):
+        d = self.root.callRemote("menus_get", language, security_limit)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def message_encryption_get(self, to_jid, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("message_encryption_get", to_jid, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@", callback=None, errback=None):
+        d = self.root.callRemote("message_encryption_start", to_jid, namespace, replace, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def message_encryption_stop(self, to_jid, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("message_encryption_stop", to_jid, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None):
+        d = self.root.callRemote("message_send", to_jid, message, subject, mess_type, extra, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def namespaces_get(self, callback=None, errback=None):
+        d = self.root.callRemote("namespaces_get")
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("param_get_a", name, category, attribute, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("param_get_a_async", name, category, attribute, security_limit, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("param_set", name, value, category, security_limit, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("param_ui_get", security_limit, app, extra, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def params_categories_get(self, callback=None, errback=None):
+        d = self.root.callRemote("params_categories_get")
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def params_register_app(self, xml, security_limit=-1, app='', callback=None, errback=None):
+        d = self.root.callRemote("params_register_app", xml, security_limit, app)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def params_template_load(self, filename, callback=None, errback=None):
+        d = self.root.callRemote("params_template_load", filename)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def params_template_save(self, filename, callback=None, errback=None):
+        d = self.root.callRemote("params_template_save", filename)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("params_values_from_category_get_async", category, security_limit, app, extra, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("presence_set", to_jid, show, statuses, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def presence_statuses_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("presence_statuses_get", profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def private_data_delete(self, namespace, key, arg_2, callback=None, errback=None):
+        d = self.root.callRemote("private_data_delete", namespace, key, arg_2)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def private_data_get(self, namespace, key, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("private_data_get", namespace, key, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def private_data_set(self, namespace, key, data, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("private_data_set", namespace, key, data, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def profile_create(self, profile, password='', component='', callback=None, errback=None):
+        d = self.root.callRemote("profile_create", profile, password, component)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def profile_delete_async(self, profile, callback=None, errback=None):
+        d = self.root.callRemote("profile_delete_async", profile)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def profile_is_session_started(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("profile_is_session_started", profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def profile_name_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("profile_name_get", profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def profile_set_default(self, profile, callback=None, errback=None):
+        d = self.root.callRemote("profile_set_default", profile)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def profile_start_session(self, password='', profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("profile_start_session", password, profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def profiles_list_get(self, clients=True, components=False, callback=None, errback=None):
+        d = self.root.callRemote("profiles_list_get", clients, components)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def progress_get(self, id, profile, callback=None, errback=None):
+        d = self.root.callRemote("progress_get", id, profile)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def progress_get_all(self, profile, callback=None, errback=None):
+        d = self.root.callRemote("progress_get_all", profile)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def progress_get_all_metadata(self, profile, callback=None, errback=None):
+        d = self.root.callRemote("progress_get_all_metadata", profile)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def ready_get(self, callback=None, errback=None):
+        d = self.root.callRemote("ready_get")
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def roster_resync(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("roster_resync", profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def session_infos_get(self, profile_key, callback=None, errback=None):
+        d = self.root.callRemote("session_infos_get", profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def sub_waiting_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("sub_waiting_get", profile_key)
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def subscription(self, sub_type, entity, profile_key="@DEFAULT@", callback=None, errback=None):
+        d = self.root.callRemote("subscription", sub_type, entity, profile_key)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+    def version_get(self, callback=None, errback=None):
+        d = self.root.callRemote("version_get")
+        if callback is not None:
+            d.addCallback(callback)
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)
+
+
+class AIOSignalsHandler(SignalsHandler):
+
+    def register_signal(self, name, handler, iface="core"):
+        async_handler = lambda *args, **kwargs: defer.Deferred.fromFuture(
+            asyncio.ensure_future(handler(*args, **kwargs)))
+        return super().register_signal(name, async_handler, iface)
+
+
+class AIOBridge(bridge):
+
+    def __init__(self):
+        self.signals_handler = AIOSignalsHandler()
+
+    def _errback(self, failure_):
+        """Convert Failure to BridgeException"""
+        raise BridgeException(
+            name=failure_.type.decode('utf-8'),
+            message=str(failure_.value)
+            )
+
+    def call(self, name, *args, **kwargs):
+        d = self.root.callRemote(name, *args, *kwargs)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    async def bridge_connect(self):
+        d = super().bridge_connect(callback=None, errback=None)
+        return await d.asFuture(asyncio.get_event_loop())
+
+    def action_launch(self, callback_id, data, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("action_launch", callback_id, data, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def actions_get(self, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("actions_get", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def config_get(self, section, name):
+        d = self.root.callRemote("config_get", section, name)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def connect(self, profile_key="@DEFAULT@", password='', options={}):
+        d = self.root.callRemote("connect", profile_key, password, options)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def contact_add(self, entity_jid, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("contact_add", entity_jid, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def contact_del(self, entity_jid, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("contact_del", entity_jid, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def contact_get(self, arg_0, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("contact_get", arg_0, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("contact_update", entity_jid, name, groups, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def contacts_get(self, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("contacts_get", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def contacts_get_from_group(self, group, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("contacts_get_from_group", group, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def devices_infos_get(self, bare_jid, profile_key):
+        d = self.root.callRemote("devices_infos_get", bare_jid, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("disco_find_by_features", namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("disco_infos", entity_jid, node, use_cache, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("disco_items", entity_jid, node, use_cache, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def disconnect(self, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("disconnect", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def encryption_namespace_get(self, arg_0):
+        d = self.root.callRemote("encryption_namespace_get", arg_0)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def encryption_plugins_get(self):
+        d = self.root.callRemote("encryption_plugins_get")
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def encryption_trust_ui_get(self, to_jid, namespace, profile_key):
+        d = self.root.callRemote("encryption_trust_ui_get", to_jid, namespace, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def entities_data_get(self, jids, keys, profile):
+        d = self.root.callRemote("entities_data_get", jids, keys, profile)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def entity_data_get(self, jid, keys, profile):
+        d = self.root.callRemote("entity_data_get", jid, keys, profile)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def features_get(self, profile_key):
+        d = self.root.callRemote("features_get", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@"):
+        d = self.root.callRemote("history_get", from_jid, to_jid, limit, between, filters, profile)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def image_check(self, arg_0):
+        d = self.root.callRemote("image_check", arg_0)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def image_convert(self, source, dest, arg_2, extra):
+        d = self.root.callRemote("image_convert", source, dest, arg_2, extra)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def image_generate_preview(self, image_path, profile_key):
+        d = self.root.callRemote("image_generate_preview", image_path, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def image_resize(self, image_path, width, height):
+        d = self.root.callRemote("image_resize", image_path, width, height)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def is_connected(self, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("is_connected", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def main_resource_get(self, contact_jid, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("main_resource_get", contact_jid, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def menu_help_get(self, menu_id, language):
+        d = self.root.callRemote("menu_help_get", menu_id, language)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def menu_launch(self, menu_type, path, data, security_limit, profile_key):
+        d = self.root.callRemote("menu_launch", menu_type, path, data, security_limit, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def menus_get(self, language, security_limit):
+        d = self.root.callRemote("menus_get", language, security_limit)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def message_encryption_get(self, to_jid, profile_key):
+        d = self.root.callRemote("message_encryption_get", to_jid, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@"):
+        d = self.root.callRemote("message_encryption_start", to_jid, namespace, replace, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def message_encryption_stop(self, to_jid, profile_key):
+        d = self.root.callRemote("message_encryption_stop", to_jid, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@"):
+        d = self.root.callRemote("message_send", to_jid, message, subject, mess_type, extra, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def namespaces_get(self):
+        d = self.root.callRemote("namespaces_get")
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@"):
+        d = self.root.callRemote("param_get_a", name, category, attribute, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("param_get_a_async", name, category, attribute, security_limit, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("param_set", name, value, category, security_limit, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@"):
+        d = self.root.callRemote("param_ui_get", security_limit, app, extra, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def params_categories_get(self):
+        d = self.root.callRemote("params_categories_get")
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def params_register_app(self, xml, security_limit=-1, app=''):
+        d = self.root.callRemote("params_register_app", xml, security_limit, app)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def params_template_load(self, filename):
+        d = self.root.callRemote("params_template_load", filename)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def params_template_save(self, filename):
+        d = self.root.callRemote("params_template_save", filename)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@"):
+        d = self.root.callRemote("params_values_from_category_get_async", category, security_limit, app, extra, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("presence_set", to_jid, show, statuses, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def presence_statuses_get(self, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("presence_statuses_get", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def private_data_delete(self, namespace, key, arg_2):
+        d = self.root.callRemote("private_data_delete", namespace, key, arg_2)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def private_data_get(self, namespace, key, profile_key):
+        d = self.root.callRemote("private_data_get", namespace, key, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def private_data_set(self, namespace, key, data, profile_key):
+        d = self.root.callRemote("private_data_set", namespace, key, data, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def profile_create(self, profile, password='', component=''):
+        d = self.root.callRemote("profile_create", profile, password, component)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def profile_delete_async(self, profile):
+        d = self.root.callRemote("profile_delete_async", profile)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def profile_is_session_started(self, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("profile_is_session_started", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def profile_name_get(self, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("profile_name_get", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def profile_set_default(self, profile):
+        d = self.root.callRemote("profile_set_default", profile)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def profile_start_session(self, password='', profile_key="@DEFAULT@"):
+        d = self.root.callRemote("profile_start_session", password, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def profiles_list_get(self, clients=True, components=False):
+        d = self.root.callRemote("profiles_list_get", clients, components)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def progress_get(self, id, profile):
+        d = self.root.callRemote("progress_get", id, profile)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def progress_get_all(self, profile):
+        d = self.root.callRemote("progress_get_all", profile)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def progress_get_all_metadata(self, profile):
+        d = self.root.callRemote("progress_get_all_metadata", profile)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def ready_get(self):
+        d = self.root.callRemote("ready_get")
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def roster_resync(self, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("roster_resync", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def session_infos_get(self, profile_key):
+        d = self.root.callRemote("session_infos_get", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def sub_waiting_get(self, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("sub_waiting_get", profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def subscription(self, sub_type, entity, profile_key="@DEFAULT@"):
+        d = self.root.callRemote("subscription", sub_type, entity, profile_key)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    def version_get(self):
+        d = self.root.callRemote("version_get")
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/arg_tools.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+
+
+def escape(arg, smart=True):
+    """format arg with quotes
+
+    @param smart(bool): if True, only escape if needed
+    """
+    if smart and not " " in arg and not '"' in arg:
+        return arg
+    return '"' + arg.replace('"', '\\"') + '"'
+
+
+def get_cmd_choices(cmd=None, parser=None):
+    try:
+        choices = parser._subparsers._group_actions[0].choices
+        return choices[cmd] if cmd is not None else choices
+    except (KeyError, AttributeError):
+        raise exceptions.NotFound
+
+
+def get_use_args(host, args, use, verbose=False, parser=None):
+    """format args for argparse parser with values prefilled
+
+    @param host(JP): jp instance
+    @param args(list(str)): arguments to use
+    @param use(dict[str, str]): arguments to fill if found in parser
+    @param verbose(bool): if True a message will be displayed when argument is used or not
+    @param parser(argparse.ArgumentParser): parser to use
+    @return (tuple[list[str],list[str]]): 2 args lists:
+        - parser args, i.e. given args corresponding to parsers
+        - use args, i.e. generated args from use
+    """
+    # FIXME: positional args are not handled correclty
+    #        if there is more that one, the position is not corrected
+    if parser is None:
+        parser = host.parser
+
+    # we check not optional args to see if there
+    # is a corresonding parser
+    # else USE args would not work correctly (only for current parser)
+    parser_args = []
+    for arg in args:
+        if arg.startswith("-"):
+            break
+        try:
+            parser = get_cmd_choices(arg, parser)
+        except exceptions.NotFound:
+            break
+        parser_args.append(arg)
+
+    # post_args are remaning given args,
+    # without the ones corresponding to parsers
+    post_args = args[len(parser_args) :]
+
+    opt_args = []
+    pos_args = []
+    actions = {a.dest: a for a in parser._actions}
+    for arg, value in use.items():
+        try:
+            if arg == "item" and not "item" in actions:
+                # small hack when --item is appended to a --items list
+                arg = "items"
+            action = actions[arg]
+        except KeyError:
+            if verbose:
+                host.disp(
+                    _(
+                        "ignoring {name}={value}, not corresponding to any argument (in USE)"
+                    ).format(name=arg, value=escape(value))
+                )
+        else:
+            if verbose:
+                host.disp(
+                    _("arg {name}={value} (in USE)").format(
+                        name=arg, value=escape(value)
+                    )
+                )
+            if not action.option_strings:
+                pos_args.append(value)
+            else:
+                opt_args.append(action.option_strings[0])
+                opt_args.append(value)
+    return parser_args, opt_args + pos_args + post_args
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/base.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1435 @@
+#!/usr/bin/env python3
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+import asyncio
+from libervia.backend.core.i18n import _
+
+### logging ###
+import logging as log
+log.basicConfig(level=log.WARNING,
+                format='[%(name)s] %(message)s')
+###
+
+import sys
+import os
+import os.path
+import argparse
+import inspect
+import tty
+import termios
+from pathlib import Path
+from glob import iglob
+from typing import Optional, Set, Union
+from importlib import import_module
+from libervia.frontends.tools.jid import JID
+from libervia.backend.tools import config
+from libervia.backend.tools.common import dynamic_import
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common import utils
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.core import exceptions
+import libervia.frontends.jp
+from libervia.frontends.jp.loops import QuitException, get_jp_loop
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.frontends.tools import misc
+import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
+from collections import OrderedDict
+
+## bridge handling
+# we get bridge name from conf and initialise the right class accordingly
+main_config = config.parse_main_conf()
+bridge_name = config.config_get(main_config, '', 'bridge', 'dbus')
+JPLoop = get_jp_loop(bridge_name)
+
+
+try:
+    import progressbar
+except ImportError:
+    msg = (_('ProgressBar not available, please download it at '
+             'http://pypi.python.org/pypi/progressbar\n'
+             'Progress bar deactivated\n--\n'))
+    print(msg, file=sys.stderr)
+    progressbar=None
+
+#consts
+DESCRIPTION = """This software is a command line tool for XMPP.
+Get the latest version at """ + C.APP_URL
+
+COPYLEFT = """Copyright (C) 2009-2021 Jérôme Poisson, Adrien Cossa
+This program comes with ABSOLUTELY NO WARRANTY;
+This is free software, and you are welcome to redistribute it under certain conditions.
+"""
+
+PROGRESS_DELAY = 0.1 # the progression will be checked every PROGRESS_DELAY s
+
+
+def date_decoder(arg):
+    return date_utils.date_parse_ext(arg, default_tz=date_utils.TZ_LOCAL)
+
+
+class LiberviaCli:
+    """
+    This class can be use to establish a connection with the
+    bridge. Moreover, it should manage a main loop.
+
+    To use it, you mainly have to redefine the method run to perform
+    specify what kind of operation you want to perform.
+
+    """
+    def __init__(self):
+        """
+
+        @attribute quit_on_progress_end (bool): set to False if you manage yourself
+            exiting, or if you want the user to stop by himself
+        @attribute progress_success(callable): method to call when progress just started
+            by default display a message
+        @attribute progress_success(callable): method to call when progress is
+            successfully finished by default display a message
+        @attribute progress_failure(callable): method to call when progress failed
+            by default display a message
+        """
+        self.sat_conf = main_config
+        self.set_color_theme()
+        bridge_module = dynamic_import.bridge(bridge_name, 'libervia.frontends.bridge')
+        if bridge_module is None:
+            log.error("Can't import {} bridge".format(bridge_name))
+            sys.exit(1)
+
+        self.bridge = bridge_module.AIOBridge()
+        self._onQuitCallbacks = []
+
+    def get_config(self, name, section=C.CONFIG_SECTION, default=None):
+        """Retrieve a setting value from sat.conf"""
+        return config.config_get(self.sat_conf, section, name, default=default)
+
+    def guess_background(self):
+        # cf. https://unix.stackexchange.com/a/245568 (thanks!)
+        try:
+            # for VTE based terminals
+            vte_version = int(os.getenv("VTE_VERSION", 0))
+        except ValueError:
+            vte_version = 0
+
+        color_fg_bg = os.getenv("COLORFGBG")
+
+        if ((sys.stdin.isatty() and sys.stdout.isatty()
+             and (
+                 # XTerm
+                 os.getenv("XTERM_VERSION")
+                 # Konsole
+                 or os.getenv("KONSOLE_VERSION")
+                 # All VTE based terminals
+                 or vte_version >= 3502
+             ))):
+            # ANSI escape sequence
+            stdin_fd = sys.stdin.fileno()
+            old_settings = termios.tcgetattr(stdin_fd)
+            try:
+                tty.setraw(sys.stdin.fileno())
+                # we request background color
+                sys.stdout.write("\033]11;?\a")
+                sys.stdout.flush()
+                expected = "\033]11;rgb:"
+                for c in expected:
+                    ch = sys.stdin.read(1)
+                    if ch != c:
+                        # background id is not supported, we default to "dark"
+                        # TODO: log something?
+                        return 'dark'
+                red, green, blue = [
+                    int(c, 16)/65535 for c in sys.stdin.read(14).split('/')
+                ]
+                # '\a' is the last character
+                sys.stdin.read(1)
+            finally:
+                termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
+
+            lum = utils.per_luminance(red, green, blue)
+            if lum <= 0.5:
+                return 'dark'
+            else:
+                return 'light'
+        elif color_fg_bg:
+            # no luck with ANSI escape sequence, we try COLORFGBG environment variable
+            try:
+                bg = int(color_fg_bg.split(";")[-1])
+            except ValueError:
+                return "dark"
+            if bg in list(range(7)) + [8]:
+                return "dark"
+            else:
+                return "light"
+        else:
+            # no autodetection method found
+            return "dark"
+
+    def set_color_theme(self):
+        background = self.get_config('background', default='auto')
+        if background == 'auto':
+            background = self.guess_background()
+        if background not in ('dark', 'light'):
+            raise exceptions.ConfigError(_(
+                'Invalid value set for "background" ({background}), please check '
+                'your settings in libervia.conf').format(
+                    background=repr(background)
+                ))
+        self.background = background
+        if background == 'light':
+            C.A_HEADER = A.FG_MAGENTA
+            C.A_SUBHEADER = A.BOLD + A.FG_RED
+            C.A_LEVEL_COLORS = (C.A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
+            C.A_SUCCESS = A.FG_GREEN
+            C.A_FAILURE = A.BOLD + A.FG_RED
+            C.A_WARNING = A.FG_RED
+            C.A_PROMPT_PATH = A.FG_BLUE
+            C.A_PROMPT_SUF = A.BOLD
+            C.A_DIRECTORY = A.BOLD + A.FG_MAGENTA
+            C.A_FILE = A.FG_BLACK
+
+    def _bridge_connected(self):
+        self.parser = argparse.ArgumentParser(
+            formatter_class=argparse.RawDescriptionHelpFormatter, description=DESCRIPTION)
+        self._make_parents()
+        self.add_parser_options()
+        self.subparsers = self.parser.add_subparsers(
+            title=_('Available commands'), dest='command', required=True)
+
+        # progress attributes
+        self._progress_id = None # TODO: manage several progress ids
+        self.quit_on_progress_end = True
+
+        # outputs
+        self._outputs = {}
+        for type_ in C.OUTPUT_TYPES:
+            self._outputs[type_] = OrderedDict()
+        self.default_output = {}
+
+        self.own_jid = None  # must be filled at runtime if needed
+
+    @property
+    def progress_id(self):
+        return self._progress_id
+
+    async def set_progress_id(self, progress_id):
+        # because we use async, we need an explicit setter
+        self._progress_id = progress_id
+        await self.replay_cache('progress_ids_cache')
+
+    @property
+    def watch_progress(self):
+        try:
+            self.pbar
+        except AttributeError:
+            return False
+        else:
+            return True
+
+    @watch_progress.setter
+    def watch_progress(self, watch_progress):
+        if watch_progress:
+            self.pbar = None
+
+    @property
+    def verbosity(self):
+        try:
+            return self.args.verbose
+        except AttributeError:
+            return 0
+
+    async def replay_cache(self, cache_attribute):
+        """Replay cached signals
+
+        @param cache_attribute(str): name of the attribute containing the cache
+            if the attribute doesn't exist, there is no cache and the call is ignored
+            else the cache must be a list of tuples containing the replay callback as
+            first item, then the arguments to use
+        """
+        try:
+            cache = getattr(self, cache_attribute)
+        except AttributeError:
+            pass
+        else:
+            for cache_data in cache:
+                await cache_data[0](*cache_data[1:])
+
+    def disp(self, msg, verbosity=0, error=False, end='\n'):
+        """Print a message to user
+
+        @param msg(unicode): message to print
+        @param verbosity(int): minimal verbosity to display the message
+        @param error(bool): if True, print to stderr instead of stdout
+        @param end(str): string appended after the last value, default a newline
+        """
+        if self.verbosity >= verbosity:
+            if error:
+                print(msg, end=end, file=sys.stderr)
+            else:
+                print(msg, end=end)
+
+    async def output(self, type_, name, extra_outputs, data):
+        if name in extra_outputs:
+            method = extra_outputs[name]
+        else:
+            method = self._outputs[type_][name]['callback']
+
+        ret = method(data)
+        if inspect.isawaitable(ret):
+            await ret
+
+    def add_on_quit_callback(self, callback, *args, **kwargs):
+        """Add a callback which will be called on quit command
+
+        @param callback(callback): method to call
+        """
+        self._onQuitCallbacks.append((callback, args, kwargs))
+
+    def get_output_choices(self, output_type):
+        """Return valid output filters for output_type
+
+        @param output_type: True for default,
+            else can be any registered type
+        """
+        return list(self._outputs[output_type].keys())
+
+    def _make_parents(self):
+        self.parents = {}
+
+        # we have a special case here as the start-session option is present only if
+        # connection is not needed, so we create two similar parents, one with the
+        # option, the other one without it
+        for parent_name in ('profile', 'profile_session'):
+            parent = self.parents[parent_name] = argparse.ArgumentParser(add_help=False)
+            parent.add_argument(
+                "-p", "--profile", action="store", type=str, default='@DEFAULT@',
+                help=_("Use PROFILE profile key (default: %(default)s)"))
+            parent.add_argument(
+                "--pwd", action="store", metavar='PASSWORD',
+                help=_("Password used to connect profile, if necessary"))
+
+        profile_parent, profile_session_parent = (self.parents['profile'],
+                                                  self.parents['profile_session'])
+
+        connect_short, connect_long, connect_action, connect_help = (
+            "-c", "--connect", "store_true",
+            _("Connect the profile before doing anything else")
+        )
+        profile_parent.add_argument(
+            connect_short, connect_long, action=connect_action, help=connect_help)
+
+        profile_session_connect_group = profile_session_parent.add_mutually_exclusive_group()
+        profile_session_connect_group.add_argument(
+            connect_short, connect_long, action=connect_action, help=connect_help)
+        profile_session_connect_group.add_argument(
+            "--start-session", action="store_true",
+            help=_("Start a profile session without connecting"))
+
+        progress_parent = self.parents['progress'] = argparse.ArgumentParser(
+            add_help=False)
+        if progressbar:
+            progress_parent.add_argument(
+                "-P", "--progress", action="store_true", help=_("Show progress bar"))
+
+        verbose_parent = self.parents['verbose'] = argparse.ArgumentParser(add_help=False)
+        verbose_parent.add_argument(
+            '--verbose', '-v', action='count', default=0,
+            help=_("Add a verbosity level (can be used multiple times)"))
+
+        quiet_parent = self.parents['quiet'] = argparse.ArgumentParser(add_help=False)
+        quiet_parent.add_argument(
+            '--quiet', '-q', action='store_true',
+            help=_("be quiet (only output machine readable data)"))
+
+        draft_parent = self.parents['draft'] = argparse.ArgumentParser(add_help=False)
+        draft_group = draft_parent.add_argument_group(_('draft handling'))
+        draft_group.add_argument(
+            "-D", "--current", action="store_true", help=_("load current draft"))
+        draft_group.add_argument(
+            "-F", "--draft-path", type=Path, help=_("path to a draft file to retrieve"))
+
+
+    def make_pubsub_group(self, flags, defaults):
+        """Generate pubsub options according to flags
+
+        @param flags(iterable[unicode]): see [CommandBase.__init__]
+        @param defaults(dict[unicode, unicode]): help text for default value
+            key can be "service" or "node"
+            value will be set in " (DEFAULT: {value})", or can be None to remove DEFAULT
+        @return (ArgumentParser): parser to add
+        """
+        flags = misc.FlagsHandler(flags)
+        parent = argparse.ArgumentParser(add_help=False)
+        pubsub_group = parent.add_argument_group('pubsub')
+        pubsub_group.add_argument("-u", "--pubsub-url",
+                                  help=_("Pubsub URL (xmpp or http)"))
+
+        service_help = _("JID of the PubSub service")
+        if not flags.service:
+            default = defaults.pop('service', _('PEP service'))
+            if default is not None:
+                service_help += _(" (DEFAULT: {default})".format(default=default))
+        pubsub_group.add_argument("-s", "--service", default='',
+                                  help=service_help)
+
+        node_help = _("node to request")
+        if not flags.node:
+            default = defaults.pop('node', _('standard node'))
+            if default is not None:
+                node_help += _(" (DEFAULT: {default})".format(default=default))
+        pubsub_group.add_argument("-n", "--node", default='', help=node_help)
+
+        if flags.single_item:
+            item_help = ("item to retrieve")
+            if not flags.item:
+                default = defaults.pop('item', _('last item'))
+                if default is not None:
+                    item_help += _(" (DEFAULT: {default})".format(default=default))
+            pubsub_group.add_argument("-i", "--item", default='',
+                                      help=item_help)
+            pubsub_group.add_argument(
+                "-L", "--last-item", action='store_true', help=_('retrieve last item'))
+        elif flags.multi_items:
+            # mutiple items, this activate several features: max-items, RSM, MAM
+            # and Orbder-by
+            pubsub_group.add_argument(
+                "-i", "--item", action='append', dest='items', default=[],
+                help=_("items to retrieve (DEFAULT: all)"))
+            if not flags.no_max:
+                max_group = pubsub_group.add_mutually_exclusive_group()
+                # XXX: defaut value for --max-items or --max is set in parse_pubsub_args
+                max_group.add_argument(
+                    "-M", "--max-items", dest="max", type=int,
+                    help=_("maximum number of items to get ({no_limit} to get all items)"
+                           .format(no_limit=C.NO_LIMIT)))
+                # FIXME: it could be possible to no duplicate max (between pubsub
+                #        max-items and RSM max)should not be duplicated, RSM could be
+                #        used when available and pubsub max otherwise
+                max_group.add_argument(
+                    "-m", "--max", dest="rsm_max", type=int,
+                    help=_("maximum number of items to get per page (DEFAULT: 10)"))
+
+            # RSM
+
+            rsm_page_group = pubsub_group.add_mutually_exclusive_group()
+            rsm_page_group.add_argument(
+                "-a", "--after", dest="rsm_after",
+                help=_("find page after this item"), metavar='ITEM_ID')
+            rsm_page_group.add_argument(
+                "-b", "--before", dest="rsm_before",
+                help=_("find page before this item"), metavar='ITEM_ID')
+            rsm_page_group.add_argument(
+                "--index", dest="rsm_index", type=int,
+                help=_("index of the first item to retrieve"))
+
+
+            # MAM
+
+            pubsub_group.add_argument(
+                "-f", "--filter", dest='mam_filters', nargs=2,
+                action='append', default=[], help=_("MAM filters to use"),
+                metavar=("FILTER_NAME", "VALUE")
+            )
+
+            # Order-By
+
+            # TODO: order-by should be a list to handle several levels of ordering
+            #       but this is not yet done in SàT (and not really useful with
+            #       current specifications, as only "creation" and "modification" are
+            #       available)
+            pubsub_group.add_argument(
+                "-o", "--order-by", choices=[C.ORDER_BY_CREATION,
+                                             C.ORDER_BY_MODIFICATION],
+                help=_("how items should be ordered"))
+
+        if flags[C.CACHE]:
+            pubsub_group.add_argument(
+                "-C", "--no-cache", dest="use_cache", action='store_false',
+                help=_("don't use Pubsub cache")
+            )
+
+        if not flags.all_used:
+            raise exceptions.InternalError('unknown flags: {flags}'.format(
+                flags=', '.join(flags.unused)))
+        if defaults:
+            raise exceptions.InternalError(f'unused defaults: {defaults}')
+
+        return parent
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            '--version',
+            action='version',
+            version=("{name} {version} {copyleft}".format(
+                name = C.APP_NAME,
+                version = self.version,
+                copyleft = COPYLEFT))
+        )
+
+    def register_output(self, type_, name, callback, description="", default=False):
+        if type_ not in C.OUTPUT_TYPES:
+            log.error("Invalid output type {}".format(type_))
+            return
+        self._outputs[type_][name] = {'callback': callback,
+                                      'description': description
+                                     }
+        if default:
+            if type_ in self.default_output:
+                self.disp(
+                    _('there is already a default output for {type}, ignoring new one')
+                    .format(type=type_)
+                )
+            else:
+                self.default_output[type_] = name
+
+
+    def parse_output_options(self):
+        options = self.command.args.output_opts
+        options_dict = {}
+        for option in options:
+            try:
+                key, value = option.split('=', 1)
+            except ValueError:
+                key, value = option, None
+            options_dict[key.strip()] = value.strip() if value is not None else None
+        return options_dict
+
+    def check_output_options(self, accepted_set, options):
+        if not accepted_set.issuperset(options):
+            self.disp(
+                _("The following output options are invalid: {invalid_options}").format(
+                invalid_options = ', '.join(set(options).difference(accepted_set))),
+                error=True)
+            self.quit(C.EXIT_BAD_ARG)
+
+    def import_plugins(self):
+        """Automaticaly import commands and outputs in jp
+
+        looks from modules names cmd_*.py in jp path and import them
+        """
+        path = os.path.dirname(libervia.frontends.jp.__file__)
+        # XXX: outputs must be imported before commands as they are used for arguments
+        for type_, pattern in ((C.PLUGIN_OUTPUT, 'output_*.py'),
+                               (C.PLUGIN_CMD, 'cmd_*.py')):
+            modules = (
+                os.path.splitext(module)[0]
+                for module in map(os.path.basename, iglob(os.path.join(path, pattern))))
+            for module_name in modules:
+                module_path = "libervia.frontends.jp." + module_name
+                try:
+                    module = import_module(module_path)
+                    self.import_plugin_module(module, type_)
+                except ImportError as e:
+                    self.disp(
+                        _("Can't import {module_path} plugin, ignoring it: {e}")
+                        .format(module_path=module_path, e=e),
+                        error=True)
+                except exceptions.CancelError:
+                    continue
+                except exceptions.MissingModule as e:
+                    self.disp(_("Missing module for plugin {name}: {missing}".format(
+                        name = module_path,
+                        missing = e)), error=True)
+
+
+    def import_plugin_module(self, module, type_):
+        """add commands or outpus from a module to jp
+
+        @param module: module containing commands or outputs
+        @param type_(str): one of C_PLUGIN_*
+        """
+        try:
+            class_names =  getattr(module, '__{}__'.format(type_))
+        except AttributeError:
+            log.disp(
+                _("Invalid plugin module [{type}] {module}")
+                .format(type=type_, module=module),
+                error=True)
+            raise ImportError
+        else:
+            for class_name in class_names:
+                cls = getattr(module, class_name)
+                cls(self)
+
+    def get_xmpp_uri_from_http(self, http_url):
+        """parse HTML page at http(s) URL, and looks for xmpp: uri"""
+        if http_url.startswith('https'):
+            scheme = 'https'
+        elif http_url.startswith('http'):
+            scheme = 'http'
+        else:
+            raise exceptions.InternalError('An HTTP scheme is expected in this method')
+        self.disp(f"{scheme.upper()} URL found, trying to find associated xmpp: URI", 1)
+        # HTTP URL, we try to find xmpp: links
+        try:
+            from lxml import etree
+        except ImportError:
+            self.disp(
+                "lxml module must be installed to use http(s) scheme, please install it "
+                "with \"pip install lxml\"",
+                error=True)
+            self.quit(1)
+        import urllib.request, urllib.error, urllib.parse
+        parser = etree.HTMLParser()
+        try:
+            root = etree.parse(urllib.request.urlopen(http_url), parser)
+        except etree.XMLSyntaxError as e:
+            self.disp(_("Can't parse HTML page : {msg}").format(msg=e))
+            links = []
+        else:
+            links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]")
+        if not links:
+            self.disp(
+                _('Could not find alternate "xmpp:" URI, can\'t find associated XMPP '
+                  'PubSub node/item'),
+                error=True)
+            self.quit(1)
+        xmpp_uri = links[0].get('href')
+        return xmpp_uri
+
+    def parse_pubsub_args(self):
+        if self.args.pubsub_url is not None:
+            url = self.args.pubsub_url
+
+            if url.startswith('http'):
+                # http(s) URL, we try to retrieve xmpp one from there
+                url = self.get_xmpp_uri_from_http(url)
+
+            try:
+                uri_data = uri.parse_xmpp_uri(url)
+            except ValueError:
+                self.parser.error(_('invalid XMPP URL: {url}').format(url=url))
+            else:
+                if uri_data['type'] == 'pubsub':
+                    # URL is alright, we only set data not already set by other options
+                    if not self.args.service:
+                        self.args.service = uri_data['path']
+                    if not self.args.node:
+                        self.args.node = uri_data['node']
+                    uri_item = uri_data.get('item')
+                    if uri_item:
+                        # there is an item in URI
+                        # we use it only if item is not already set
+                        # and item_last is not used either
+                        try:
+                            item = self.args.item
+                        except AttributeError:
+                            try:
+                                items = self.args.items
+                            except AttributeError:
+                                self.disp(
+                                    _("item specified in URL but not needed in command, "
+                                      "ignoring it"),
+                                    error=True)
+                            else:
+                                if not items:
+                                    self.args.items = [uri_item]
+                        else:
+                            if not item:
+                                try:
+                                    item_last = self.args.item_last
+                                except AttributeError:
+                                    item_last = False
+                                if not item_last:
+                                    self.args.item = uri_item
+                else:
+                    self.parser.error(
+                        _('XMPP URL is not a pubsub one: {url}').format(url=url)
+                    )
+        flags = self.args._cmd._pubsub_flags
+        # we check required arguments here instead of using add_arguments' required option
+        # because the required argument can be set in URL
+        if C.SERVICE in flags and not self.args.service:
+            self.parser.error(_("argument -s/--service is required"))
+        if C.NODE in flags and not self.args.node:
+            self.parser.error(_("argument -n/--node is required"))
+        if C.ITEM in flags and not self.args.item:
+            self.parser.error(_("argument -i/--item is required"))
+
+        # FIXME: mutually groups can't be nested in a group and don't support title
+        #        so we check conflict here. This may be fixed in Python 3, to be checked
+        try:
+            if self.args.item and self.args.item_last:
+                self.parser.error(
+                    _("--item and --item-last can't be used at the same time"))
+        except AttributeError:
+            pass
+
+        try:
+            max_items = self.args.max
+            rsm_max = self.args.rsm_max
+        except AttributeError:
+            pass
+        else:
+            # we need to set a default value for max, but we need to know if we want
+            # to use pubsub's max or RSM's max. The later is used if any RSM or MAM
+            # argument is set
+            if max_items is None and rsm_max is None:
+                to_check = ('mam_filters', 'rsm_max', 'rsm_after', 'rsm_before',
+                            'rsm_index')
+                if any((getattr(self.args, name) for name in to_check)):
+                    # we use RSM
+                    self.args.rsm_max = 10
+                else:
+                    # we use pubsub without RSM
+                    self.args.max = 10
+            if self.args.max is None:
+                self.args.max = C.NO_LIMIT
+
+    async def main(self, args, namespace):
+        try:
+            await self.bridge.bridge_connect()
+        except Exception as e:
+            if isinstance(e, exceptions.BridgeExceptionNoService):
+                print(
+                    _("Can't connect to Libervia backend, are you sure that it's "
+                      "launched ?")
+                )
+                self.quit(C.EXIT_BACKEND_NOT_FOUND, raise_exc=False)
+            elif isinstance(e, exceptions.BridgeInitError):
+                print(_("Can't init bridge"))
+                self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
+            else:
+                print(
+                    _("Error while initialising bridge: {e}").format(e=e)
+                )
+                self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
+            return
+        await self.bridge.ready_get()
+        self.version = await self.bridge.version_get()
+        self._bridge_connected()
+        self.import_plugins()
+        try:
+            self.args = self.parser.parse_args(args, namespace=None)
+            if self.args._cmd._use_pubsub:
+                self.parse_pubsub_args()
+            await self.args._cmd.run()
+        except SystemExit as e:
+            self.quit(e.code, raise_exc=False)
+            return
+        except QuitException:
+            return
+
+    def _run(self, args=None, namespace=None):
+        self.loop = JPLoop()
+        self.loop.run(self, args, namespace)
+
+    @classmethod
+    def run(cls):
+        cls()._run()
+
+    def _read_stdin(self, stdin_fut):
+        """Callback called by ainput to read stdin"""
+        line = sys.stdin.readline()
+        if line:
+            stdin_fut.set_result(line.rstrip(os.linesep))
+        else:
+            stdin_fut.set_exception(EOFError())
+
+    async def ainput(self, msg=''):
+        """Asynchronous version of buildin "input" function"""
+        self.disp(msg, end=' ')
+        sys.stdout.flush()
+        loop = asyncio.get_running_loop()
+        stdin_fut = loop.create_future()
+        loop.add_reader(sys.stdin, self._read_stdin, stdin_fut)
+        return await stdin_fut
+
+    async def confirm(self, message):
+        """Request user to confirm action, return answer as boolean"""
+        res = await self.ainput(f"{message} (y/N)? ")
+        return res in ("y", "Y")
+
+    async def confirm_or_quit(self, message, cancel_message=_("action cancelled by user")):
+        """Request user to confirm action, and quit if he doesn't"""
+        confirmed = await self.confirm(message)
+        if not confirmed:
+            self.disp(cancel_message)
+            self.quit(C.EXIT_USER_CANCELLED)
+
+    def quit_from_signal(self, exit_code=0):
+        r"""Same as self.quit, but from a signal handler
+
+        /!\: return must be used after calling this method !
+        """
+        # XXX: python-dbus will show a traceback if we exit in a signal handler
+        # so we use this little timeout trick to avoid it
+        self.loop.call_later(0, self.quit, exit_code)
+
+    def quit(self, exit_code=0, raise_exc=True):
+        """Terminate the execution with specified exit_code
+
+        This will stop the loop.
+        @param exit_code(int): code to return when quitting the program
+        @param raise_exp(boolean): if True raise a QuitException to stop code execution
+            The default value should be used most of time.
+        """
+        # first the onQuitCallbacks
+        try:
+            callbacks_list = self._onQuitCallbacks
+        except AttributeError:
+            pass
+        else:
+            for callback, args, kwargs in callbacks_list:
+                callback(*args, **kwargs)
+
+        self.loop.quit(exit_code)
+        if raise_exc:
+            raise QuitException
+
+    async def check_jids(self, jids):
+        """Check jids validity, transform roster name to corresponding jids
+
+        @param profile: profile name
+        @param jids: list of jids
+        @return: List of jids
+
+        """
+        names2jid = {}
+        nodes2jid = {}
+
+        try:
+            contacts = await self.bridge.contacts_get(self.profile)
+        except BridgeException as e:
+            if e.classname == "AttributeError":
+                # we may get an AttributeError if we use a component profile
+                # as components don't have roster
+                contacts = []
+            else:
+                raise e
+
+        for contact in contacts:
+            jid_s, attr, groups = contact
+            _jid = JID(jid_s)
+            try:
+                names2jid[attr["name"].lower()] = jid_s
+            except KeyError:
+                pass
+
+            if _jid.node:
+                nodes2jid[_jid.node.lower()] = jid_s
+
+        def expand_jid(jid):
+            _jid = jid.lower()
+            if _jid in names2jid:
+                expanded = names2jid[_jid]
+            elif _jid in nodes2jid:
+                expanded = nodes2jid[_jid]
+            else:
+                expanded = jid
+            return expanded
+
+        def check(jid):
+            if not jid.is_valid:
+                log.error (_("%s is not a valid JID !"), jid)
+                self.quit(1)
+
+        dest_jids=[]
+        try:
+            for i in range(len(jids)):
+                dest_jids.append(expand_jid(jids[i]))
+                check(dest_jids[i])
+        except AttributeError:
+            pass
+
+        return dest_jids
+
+    async def a_pwd_input(self, msg=''):
+        """Like ainput but with echo disabled (useful for passwords)"""
+        # we disable echo, code adapted from getpass standard module which has been
+        # written by Piers Lauder (original), Guido van Rossum (Windows support and
+        # cleanup) and Gregory P. Smith (tty support & GetPassWarning), a big thanks
+        # to them (and for all the amazing work on Python).
+        stdin_fd = sys.stdin.fileno()
+        old = termios.tcgetattr(sys.stdin)
+        new = old[:]
+        new[3] &= ~termios.ECHO
+        tcsetattr_flags = termios.TCSAFLUSH
+        if hasattr(termios, 'TCSASOFT'):
+            tcsetattr_flags |= termios.TCSASOFT
+        try:
+            termios.tcsetattr(stdin_fd, tcsetattr_flags, new)
+            pwd = await self.ainput(msg=msg)
+        finally:
+            termios.tcsetattr(stdin_fd, tcsetattr_flags, old)
+            sys.stderr.flush()
+        self.disp('')
+        return pwd
+
+    async def connect_or_prompt(self, method, err_msg=None):
+        """Try to connect/start profile session and prompt for password if needed
+
+        @param method(callable): bridge method to either connect or start profile session
+            It will be called with password as sole argument, use lambda to do the call
+            properly
+        @param err_msg(str): message to show if connection fail
+        """
+        password = self.args.pwd
+        while True:
+            try:
+                await method(password or '')
+            except Exception as e:
+                if ((isinstance(e, BridgeException)
+                     and e.classname == 'PasswordError'
+                     and self.args.pwd is None)):
+                    if password is not None:
+                        self.disp(A.color(C.A_WARNING, _("invalid password")))
+                    password = await self.a_pwd_input(
+                        _("please enter profile password:"))
+                else:
+                    self.disp(err_msg.format(profile=self.profile, e=e), error=True)
+                    self.quit(C.EXIT_ERROR)
+            else:
+                break
+
+    async def connect_profile(self):
+        """Check if the profile is connected and do it if requested
+
+        @exit: - 1 when profile is not connected and --connect is not set
+               - 1 when the profile doesn't exists
+               - 1 when there is a connection error
+        """
+        # FIXME: need better exit codes
+
+        self.profile = await self.bridge.profile_name_get(self.args.profile)
+
+        if not self.profile:
+            log.error(
+                _("The profile [{profile}] doesn't exist")
+                .format(profile=self.args.profile)
+            )
+            self.quit(C.EXIT_ERROR)
+
+        try:
+            start_session = self.args.start_session
+        except AttributeError:
+            pass
+        else:
+            if start_session:
+                await self.connect_or_prompt(
+                    lambda pwd: self.bridge.profile_start_session(pwd, self.profile),
+                    err_msg="Can't start {profile}'s session: {e}"
+                )
+                return
+            elif not await self.bridge.profile_is_session_started(self.profile):
+                if not self.args.connect:
+                    self.disp(_(
+                        "Session for [{profile}] is not started, please start it "
+                        "before using jp, or use either --start-session or --connect "
+                        "option"
+                        .format(profile=self.profile)
+                    ), error=True)
+                    self.quit(1)
+            elif not getattr(self.args, "connect", False):
+                return
+
+
+        if not hasattr(self.args, 'connect'):
+            # a profile can be present without connect option (e.g. on profile
+            # creation/deletion)
+            return
+        elif self.args.connect is True:  # if connection is asked, we connect the profile
+            await self.connect_or_prompt(
+                lambda pwd: self.bridge.connect(self.profile, pwd, {}),
+                err_msg = 'Can\'t connect profile "{profile!s}": {e}'
+            )
+            return
+        else:
+            if not await self.bridge.is_connected(self.profile):
+                log.error(
+                    _("Profile [{profile}] is not connected, please connect it "
+                      "before using jp, or use --connect option")
+                    .format(profile=self.profile)
+                )
+                self.quit(1)
+
+    async def get_full_jid(self, param_jid):
+        """Return the full jid if possible (add main resource when find a bare jid)"""
+        # TODO: to be removed, bare jid should work with all commands, notably for file
+        #   as backend now handle jingles message initiation
+        _jid = JID(param_jid)
+        if not _jid.resource:
+            #if the resource is not given, we try to add the main resource
+            main_resource = await self.bridge.main_resource_get(param_jid, self.profile)
+            if main_resource:
+                return f"{_jid.bare}/{main_resource}"
+        return param_jid
+
+    async def get_profile_jid(self):
+        """Retrieve current profile bare JID if possible"""
+        full_jid = await self.bridge.param_get_a_async(
+            "JabberID", "Connection", profile_key=self.profile
+        )
+        return full_jid.rsplit("/", 1)[0]
+
+
+class CommandBase:
+
+    def __init__(
+        self,
+        host: LiberviaCli,
+        name: str,
+        use_profile: bool = True,
+        use_output: Union[bool, str] = False,
+        extra_outputs: Optional[dict] = None,
+        need_connect: Optional[bool] = None,
+        help: Optional[str] = None,
+        **kwargs
+    ):
+        """Initialise CommandBase
+
+        @param host: Jp instance
+        @param name: name of the new command
+        @param use_profile: if True, add profile selection/connection commands
+        @param use_output: if not False, add --output option
+        @param extra_outputs: list of command specific outputs:
+            key is output name ("default" to use as main output)
+            value is a callable which will format the output (data will be used as only
+            argument)
+            if a key already exists with normal outputs, the extra one will be used
+        @param need_connect: True if profile connection is needed
+            False else (profile session must still be started)
+            None to set auto value (i.e. True if use_profile is set)
+            Can't be set if use_profile is False
+        @param help: help message to display
+        @param **kwargs: args passed to ArgumentParser
+            use_* are handled directly, they can be:
+            - use_progress(bool): if True, add progress bar activation option
+                progress* signals will be handled
+            - use_verbose(bool): if True, add verbosity option
+            - use_pubsub(bool): if True, add pubsub options
+                mandatory arguments are controlled by pubsub_req
+            - use_draft(bool): if True, add draft handling options
+            ** other arguments **
+            - pubsub_flags(iterable[unicode]): tuple of flags to set pubsub options,
+              can be:
+                C.SERVICE: service is required
+                C.NODE: node is required
+                C.ITEM: item is required
+                C.SINGLE_ITEM: only one item is allowed
+        """
+        try: # If we have subcommands, host is a CommandBase and we need to use host.host
+            self.host = host.host
+        except AttributeError:
+            self.host = host
+
+        # --profile option
+        parents = kwargs.setdefault('parents', set())
+        if use_profile:
+            # self.host.parents['profile'] is an ArgumentParser with profile connection
+            # arguments
+            if need_connect is None:
+                need_connect = True
+            parents.add(
+                self.host.parents['profile' if need_connect else 'profile_session'])
+        else:
+            assert need_connect is None
+        self.need_connect = need_connect
+        # from this point, self.need_connect is None if connection is not needed at all
+        # False if session starting is needed, and True if full connection is needed
+
+        # --output option
+        if use_output:
+            if extra_outputs is None:
+                extra_outputs = {}
+            self.extra_outputs = extra_outputs
+            if use_output == True:
+                use_output = C.OUTPUT_TEXT
+            assert use_output in C.OUTPUT_TYPES
+            self._output_type = use_output
+            output_parent = argparse.ArgumentParser(add_help=False)
+            choices = set(self.host.get_output_choices(use_output))
+            choices.update(extra_outputs)
+            if not choices:
+                raise exceptions.InternalError(
+                    "No choice found for {} output type".format(use_output))
+            try:
+                default = self.host.default_output[use_output]
+            except KeyError:
+                if 'default' in choices:
+                    default = 'default'
+                elif 'simple' in choices:
+                    default = 'simple'
+                else:
+                    default = list(choices)[0]
+            output_parent.add_argument(
+                '--output', '-O', choices=sorted(choices), default=default,
+                help=_("select output format (default: {})".format(default)))
+            output_parent.add_argument(
+                '--output-option', '--oo', action="append", dest='output_opts',
+                default=[], help=_("output specific option"))
+            parents.add(output_parent)
+        else:
+            assert extra_outputs is None
+
+        self._use_pubsub = kwargs.pop('use_pubsub', False)
+        if self._use_pubsub:
+            flags = kwargs.pop('pubsub_flags', [])
+            defaults = kwargs.pop('pubsub_defaults', {})
+            parents.add(self.host.make_pubsub_group(flags, defaults))
+            self._pubsub_flags = flags
+
+        # other common options
+        use_opts = {k:v for k,v in kwargs.items() if k.startswith('use_')}
+        for param, do_use in use_opts.items():
+            opt=param[4:] # if param is use_verbose, opt is verbose
+            if opt not in self.host.parents:
+                raise exceptions.InternalError("Unknown parent option {}".format(opt))
+            del kwargs[param]
+            if do_use:
+                parents.add(self.host.parents[opt])
+
+        self.parser = host.subparsers.add_parser(name, help=help, **kwargs)
+        if hasattr(self, "subcommands"):
+            self.subparsers = self.parser.add_subparsers(dest='subcommand', required=True)
+        else:
+            self.parser.set_defaults(_cmd=self)
+        self.add_parser_options()
+
+    @property
+    def sat_conf(self):
+        return self.host.sat_conf
+
+    @property
+    def args(self):
+        return self.host.args
+
+    @property
+    def profile(self):
+        return self.host.profile
+
+    @property
+    def verbosity(self):
+        return self.host.verbosity
+
+    @property
+    def progress_id(self):
+        return self.host.progress_id
+
+    async def set_progress_id(self, progress_id):
+        return await self.host.set_progress_id(progress_id)
+
+    async def progress_started_handler(self, uid, metadata, profile):
+        if profile != self.profile:
+            return
+        if self.progress_id is None:
+            # the progress started message can be received before the id
+            # so we keep progress_started signals in cache to replay they
+            # when the progress_id is received
+            cache_data = (self.progress_started_handler, uid, metadata, profile)
+            try:
+                cache = self.host.progress_ids_cache
+            except AttributeError:
+                cache = self.host.progress_ids_cache = []
+            cache.append(cache_data)
+        else:
+            if self.host.watch_progress and uid == self.progress_id:
+                await self.on_progress_started(metadata)
+                while True:
+                    await asyncio.sleep(PROGRESS_DELAY)
+                    cont = await self.progress_update()
+                    if not cont:
+                        break
+
+    async def progress_finished_handler(self, uid, metadata, profile):
+        if profile != self.profile:
+            return
+        if uid == self.progress_id:
+            try:
+                self.host.pbar.finish()
+            except AttributeError:
+                pass
+            await self.on_progress_finished(metadata)
+            if self.host.quit_on_progress_end:
+                self.host.quit_from_signal()
+
+    async def progress_error_handler(self, uid, message, profile):
+        if profile != self.profile:
+            return
+        if uid == self.progress_id:
+            if self.args.progress:
+                self.disp('') # progress is not finished, so we skip a line
+            if self.host.quit_on_progress_end:
+                await self.on_progress_error(message)
+                self.host.quit_from_signal(C.EXIT_ERROR)
+
+    async def progress_update(self):
+        """This method is continualy called to update the progress bar
+
+        @return (bool): False to stop being called
+        """
+        data = await self.host.bridge.progress_get(self.progress_id, self.profile)
+        if data:
+            try:
+                size = data['size']
+            except KeyError:
+                self.disp(_("file size is not known, we can't show a progress bar"), 1,
+                          error=True)
+                return False
+            if self.host.pbar is None:
+                #first answer, we must construct the bar
+
+                # if the instance has a pbar_template attribute, it is used has model,
+                # else default one is used
+                # template is a list of part, where part can be either a str to show directly
+                # or a list where first argument is a name of a progressbar widget, and others
+                # are used as widget arguments
+                try:
+                    template = self.pbar_template
+                except AttributeError:
+                    template = [
+                        _("Progress: "), ["Percentage"], " ", ["Bar"], " ",
+                        ["FileTransferSpeed"], " ", ["ETA"]
+                    ]
+
+                widgets = []
+                for part in template:
+                    if isinstance(part, str):
+                        widgets.append(part)
+                    else:
+                        widget = getattr(progressbar, part.pop(0))
+                        widgets.append(widget(*part))
+
+                self.host.pbar = progressbar.ProgressBar(max_value=int(size), widgets=widgets)
+                self.host.pbar.start()
+
+            self.host.pbar.update(int(data['position']))
+
+        elif self.host.pbar is not None:
+            return False
+
+        await self.on_progress_update(data)
+
+        return True
+
+    async def on_progress_started(self, metadata):
+        """Called when progress has just started
+
+        can be overidden by a command
+        @param metadata(dict): metadata as sent by bridge.progress_started
+        """
+        self.disp(_("Operation started"), 2)
+
+    async def on_progress_update(self, metadata):
+        """Method called on each progress updata
+
+        can be overidden by a command to handle progress metadata
+        @para metadata(dict): metadata as returned by bridge.progress_get
+        """
+        pass
+
+    async def on_progress_finished(self, metadata):
+        """Called when progress has just finished
+
+        can be overidden by a command
+        @param metadata(dict): metadata as sent by bridge.progress_finished
+        """
+        self.disp(_("Operation successfully finished"), 2)
+
+    async def on_progress_error(self, e):
+        """Called when a progress failed
+
+        @param error_msg(unicode): error message as sent by bridge.progress_error
+        """
+        self.disp(_("Error while doing operation: {e}").format(e=e), error=True)
+
+    def disp(self, msg, verbosity=0, error=False, end='\n'):
+        return self.host.disp(msg, verbosity, error, end)
+
+    def output(self, data):
+        try:
+            output_type = self._output_type
+        except AttributeError:
+            raise exceptions.InternalError(
+                _('trying to use output when use_output has not been set'))
+        return self.host.output(output_type, self.args.output, self.extra_outputs, data)
+
+    def get_pubsub_extra(self, extra: Optional[dict] = None) -> str:
+        """Helper method to compute extra data from pubsub arguments
+
+        @param extra: base extra dict, or None to generate a new one
+        @return: serialised dict which can be used directly in the bridge for pubsub
+        """
+        if extra is None:
+            extra = {}
+        else:
+            intersection = {C.KEY_ORDER_BY}.intersection(list(extra.keys()))
+            if intersection:
+                raise exceptions.ConflictError(
+                    "given extra dict has conflicting keys with pubsub keys "
+                    "{intersection}".format(intersection=intersection))
+
+        # RSM
+
+        for attribute in ('max', 'after', 'before', 'index'):
+            key = 'rsm_' + attribute
+            if key in extra:
+                raise exceptions.ConflictError(
+                    "This key already exists in extra: u{key}".format(key=key))
+            value = getattr(self.args, key, None)
+            if value is not None:
+                extra[key] = str(value)
+
+        # MAM
+
+        if hasattr(self.args, 'mam_filters'):
+            for key, value in self.args.mam_filters:
+                key = 'filter_' + key
+                if key in extra:
+                    raise exceptions.ConflictError(
+                        "This key already exists in extra: u{key}".format(key=key))
+                extra[key] = value
+
+        # Order-By
+
+        try:
+            order_by = self.args.order_by
+        except AttributeError:
+            pass
+        else:
+            if order_by is not None:
+                extra[C.KEY_ORDER_BY] = self.args.order_by
+
+        # Cache
+        try:
+            use_cache = self.args.use_cache
+        except AttributeError:
+            pass
+        else:
+            if not use_cache:
+                extra[C.KEY_USE_CACHE] = use_cache
+
+        return data_format.serialise(extra)
+
+    def add_parser_options(self):
+        try:
+            subcommands = self.subcommands
+        except AttributeError:
+            # We don't have subcommands, the class need to implements add_parser_options
+            raise NotImplementedError
+
+        # now we add subcommands to ourself
+        for cls in subcommands:
+            cls(self)
+
+    def override_pubsub_flags(self, new_flags: Set[str]) -> None:
+        """Replace pubsub_flags given in __init__
+
+        useful when a command is extending an other command (e.g. blog command which does
+        the same as pubsub command, but with a default node)
+        """
+        self._pubsub_flags = new_flags
+
+    async def run(self):
+        """this method is called when a command is actually run
+
+        It set stuff like progression callbacks and profile connection
+        You should not overide this method: you should call self.start instead
+        """
+        # we keep a reference to run command, it may be useful e.g. for outputs
+        self.host.command = self
+
+        try:
+            show_progress = self.args.progress
+        except AttributeError:
+            # the command doesn't use progress bar
+            pass
+        else:
+            if show_progress:
+                self.host.watch_progress = True
+            # we need to register the following signal even if we don't display the
+            # progress bar
+            self.host.bridge.register_signal(
+                "progress_started", self.progress_started_handler)
+            self.host.bridge.register_signal(
+                "progress_finished", self.progress_finished_handler)
+            self.host.bridge.register_signal(
+                "progress_error", self.progress_error_handler)
+
+        if self.need_connect is not None:
+            await self.host.connect_profile()
+        await self.start()
+
+    async def start(self):
+        """This is the starting point of the command, this method must be overriden
+
+        at this point, profile are connected if needed
+        """
+        raise NotImplementedError
+
+
+class CommandAnswering(CommandBase):
+    """Specialised commands which answer to specific actions
+
+    to manage action_types answer,
+    """
+    action_callbacks = {} # XXX: set managed action types in a dict here:
+                          # key is the action_type, value is the callable
+                          # which will manage the answer. profile filtering is
+                          # already managed when callback is called
+
+    def __init__(self, *args, **kwargs):
+        super(CommandAnswering, self).__init__(*args, **kwargs)
+
+    async def on_action_new(
+        self,
+        action_data_s: str,
+        action_id: str,
+        security_limit: int,
+        profile: str
+    ) -> None:
+        if profile != self.profile:
+            return
+        action_data = data_format.deserialise(action_data_s)
+        try:
+            action_type = action_data['type']
+        except KeyError:
+            try:
+                xml_ui = action_data["xmlui"]
+            except KeyError:
+                pass
+            else:
+                self.on_xmlui(xml_ui)
+        else:
+            try:
+                callback = self.action_callbacks[action_type]
+            except KeyError:
+                pass
+            else:
+                await callback(action_data, action_id, security_limit, profile)
+
+    def on_xmlui(self, xml_ui):
+        """Display a dialog received from the backend.
+
+        @param xml_ui (unicode): dialog XML representation
+        """
+        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
+        #        should be available in the future
+        # TODO: XMLUI module
+        ui = ET.fromstring(xml_ui.encode('utf-8'))
+        dialog = ui.find("dialog")
+        if dialog is not None:
+            self.disp(dialog.findtext("message"), error=dialog.get("level") == "error")
+
+    async def start_answering(self):
+        """Auto reply to confirmation requests"""
+        self.host.bridge.register_signal("action_new", self.on_action_new)
+        actions = await self.host.bridge.actions_get(self.profile)
+        for action_data_s, action_id, security_limit in actions:
+            await self.on_action_new(action_data_s, action_id, security_limit, self.profile)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_account.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+"""This module permits to manage XMPP accounts using in-band registration (XEP-0077)"""
+
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp import base
+from libervia.frontends.tools import jid
+
+
+log = getLogger(__name__)
+
+__commands__ = ["Account"]
+
+
+class AccountCreate(base.CommandBase):
+    def __init__(self, host):
+        super(AccountCreate, self).__init__(
+            host,
+            "create",
+            use_profile=False,
+            use_verbose=True,
+            help=_("create a XMPP account"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid", help=_("jid to create")
+        )
+        self.parser.add_argument(
+            "password", help=_("password of the account")
+        )
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            help=_(
+                "create a profile to use this account (default: don't create profile)"
+            ),
+        )
+        self.parser.add_argument(
+            "-e",
+            "--email",
+            default="",
+            help=_("email (usage depends of XMPP server)"),
+        )
+        self.parser.add_argument(
+            "-H",
+            "--host",
+            default="",
+            help=_("server host (IP address or domain, default: use localhost)"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--port",
+            type=int,
+            default=0,
+            help=_("server port (default: {port})").format(
+                port=C.XMPP_C2S_PORT
+            ),
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.in_band_account_new(
+                self.args.jid,
+                self.args.password,
+                self.args.email,
+                self.args.host,
+                self.args.port,
+            )
+
+        except BridgeException as e:
+            if e.condition == 'conflict':
+                self.disp(
+                    f"The account {self.args.jid} already exists",
+                    error=True
+                )
+                self.host.quit(C.EXIT_CONFLICT)
+            else:
+                self.disp(
+                    f"can't create account on {self.args.host or 'localhost'!r} with jid "
+                    f"{self.args.jid!r} using In-Band Registration: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+
+        self.disp(_("XMPP account created"), 1)
+
+        if self.args.profile is None:
+            self.host.quit()
+
+
+        self.disp(_("creating profile"), 2)
+        try:
+            await self.host.bridge.profile_create(
+                self.args.profile,
+                self.args.password,
+                "",
+            )
+        except BridgeException as e:
+            if e.condition == 'conflict':
+                self.disp(
+                    f"The profile {self.args.profile} already exists",
+                    error=True
+                )
+                self.host.quit(C.EXIT_CONFLICT)
+            else:
+                self.disp(
+                    _("Can't create profile {profile} to associate with jid "
+                      "{jid}: {e}").format(
+                          profile=self.args.profile,
+                          jid=self.args.jid,
+                          e=e
+                      ),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+
+        self.disp(_("profile created"), 1)
+        try:
+            await self.host.bridge.profile_start_session(
+                self.args.password,
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't start profile session: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        try:
+            await self.host.bridge.param_set(
+                "JabberID",
+                self.args.jid,
+                "Connection",
+                profile_key=self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set JabberID parameter: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        try:
+            await self.host.bridge.param_set(
+                "Password",
+                self.args.password,
+                "Connection",
+                profile_key=self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set Password parameter: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.disp(
+            f"profile {self.args.profile} successfully created and associated to the new "
+            f"account", 1)
+        self.host.quit()
+
+
+class AccountModify(base.CommandBase):
+    def __init__(self, host):
+        super(AccountModify, self).__init__(
+            host, "modify", help=_("change password for XMPP account")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "password", help=_("new XMPP password")
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.in_band_password_change(
+                self.args.password,
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't change XMPP password: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class AccountDelete(base.CommandBase):
+    def __init__(self, host):
+        super(AccountDelete, self).__init__(
+            host, "delete", help=_("delete a XMPP account")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("delete account without confirmation"),
+        )
+
+    async def start(self):
+        try:
+            jid_str = await self.host.bridge.param_get_a_async(
+                "JabberID",
+                "Connection",
+                profile_key=self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get JID of the profile: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        jid_ = jid.JID(jid_str)
+        if not self.args.force:
+            message = (
+                f"You are about to delete the XMPP account with jid {jid_!r}\n"
+                f"This is the XMPP account of profile {self.profile!r}\n"
+                f"Are you sure that you want to delete this account?"
+            )
+            await self.host.confirm_or_quit(message, _("Account deletion cancelled"))
+
+        try:
+            await self.host.bridge.in_band_unregister(jid_.domain, self.args.profile)
+        except Exception as e:
+            self.disp(f"can't delete XMPP account with jid {jid_!r}: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+
+class Account(base.CommandBase):
+    subcommands = (AccountCreate, AccountModify, AccountDelete)
+
+    def __init__(self, host):
+        super(Account, self).__init__(
+            host, "account", use_profile=False, help=("XMPP account management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_adhoc.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.jp import xmlui_manager
+
+__commands__ = ["AdHoc"]
+
+FLAG_LOOP = "LOOP"
+MAGIC_BAREJID = "@PROFILE_BAREJID@"
+
+
+class Remote(base.CommandBase):
+    def __init__(self, host):
+        super(Remote, self).__init__(
+            host, "remote", use_verbose=True, help=_("remote control a software")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("software", type=str, help=_("software name"))
+        self.parser.add_argument(
+            "-j",
+            "--jids",
+            nargs="*",
+            default=[],
+            help=_("jids allowed to use the command"),
+        )
+        self.parser.add_argument(
+            "-g",
+            "--groups",
+            nargs="*",
+            default=[],
+            help=_("groups allowed to use the command"),
+        )
+        self.parser.add_argument(
+            "--forbidden-groups",
+            nargs="*",
+            default=[],
+            help=_("groups that are *NOT* allowed to use the command"),
+        )
+        self.parser.add_argument(
+            "--forbidden-jids",
+            nargs="*",
+            default=[],
+            help=_("jids that are *NOT* allowed to use the command"),
+        )
+        self.parser.add_argument(
+            "-l", "--loop", action="store_true", help=_("loop on the commands")
+        )
+
+    async def start(self):
+        name = self.args.software.lower()
+        flags = []
+        magics = {jid for jid in self.args.jids if jid.count("@") > 1}
+        magics.add(MAGIC_BAREJID)
+        jids = set(self.args.jids).difference(magics)
+        if self.args.loop:
+            flags.append(FLAG_LOOP)
+        try:
+            bus_name, methods = await self.host.bridge.ad_hoc_dbus_add_auto(
+                name,
+                list(jids),
+                self.args.groups,
+                magics,
+                self.args.forbidden_jids,
+                self.args.forbidden_groups,
+                flags,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create remote control: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if not bus_name:
+                self.disp(_("No bus name found"), 1)
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(_("Bus name found: [%s]" % bus_name), 1)
+                for method in methods:
+                    path, iface, command = method
+                    self.disp(
+                        _("Command found: (path:{path}, iface: {iface}) [{command}]")
+                        .format(path=path, iface=iface, command=command),
+                        1,
+                    )
+                self.host.quit()
+
+
+class Run(base.CommandBase):
+    """Run an Ad-Hoc command"""
+
+    def __init__(self, host):
+        super(Run, self).__init__(
+            host, "run", use_verbose=True, help=_("run an Ad-Hoc command")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j",
+            "--jid",
+            default="",
+            help=_("jid of the service (default: profile's server"),
+        )
+        self.parser.add_argument(
+            "-S",
+            "--submit",
+            action="append_const",
+            const=xmlui_manager.SUBMIT,
+            dest="workflow",
+            help=_("submit form/page"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="workflow",
+            metavar=("KEY", "VALUE"),
+            help=_("field value"),
+        )
+        self.parser.add_argument(
+            "node",
+            nargs="?",
+            default="",
+            help=_("node of the command (default: list commands)"),
+        )
+
+    async def start(self):
+        try:
+            xmlui_raw = await self.host.bridge.ad_hoc_run(
+                self.args.jid,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get ad-hoc commands list: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            xmlui = xmlui_manager.create(self.host, xmlui_raw)
+            workflow = self.args.workflow
+            await xmlui.show(workflow)
+            if not workflow:
+                if xmlui.type == "form":
+                    await xmlui.submit_form()
+            self.host.quit()
+
+
+class List(base.CommandBase):
+    """List Ad-Hoc commands available on a service"""
+
+    def __init__(self, host):
+        super(List, self).__init__(
+            host, "list", use_verbose=True, help=_("list Ad-Hoc commands of a service")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j",
+            "--jid",
+            default="",
+            help=_("jid of the service (default: profile's server)"),
+        )
+
+    async def start(self):
+        try:
+            xmlui_raw = await self.host.bridge.ad_hoc_list(
+                self.args.jid,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get ad-hoc commands list: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            xmlui = xmlui_manager.create(self.host, xmlui_raw)
+            await xmlui.show(read_only=True)
+            self.host.quit()
+
+
+class AdHoc(base.CommandBase):
+    subcommands = (Run, List, Remote)
+
+    def __init__(self, host):
+        super(AdHoc, self).__init__(
+            host, "ad-hoc", use_profile=False, help=_("Ad-hoc commands")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_application.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.frontends.jp.constants import Const as C
+
+__commands__ = ["Application"]
+
+
+class List(base.CommandBase):
+    """List available applications"""
+
+    def __init__(self, host):
+        super(List, self).__init__(
+            host, "list", use_profile=False, use_output=C.OUTPUT_LIST,
+            help=_("list available applications")
+        )
+
+    def add_parser_options(self):
+        # FIXME: "extend" would be better here, but it's only available from Python 3.8+
+        #   so we use "append" until minimum version of Python is raised.
+        self.parser.add_argument(
+            "-f",
+            "--filter",
+            dest="filters",
+            action="append",
+            choices=["available", "running"],
+            help=_("show applications with this status"),
+        )
+
+    async def start(self):
+
+        # FIXME: this is only needed because we can't use "extend" in
+        #   add_parser_options, see note there
+        if self.args.filters:
+            self.args.filters = list(set(self.args.filters))
+        else:
+            self.args.filters = ['available']
+
+        try:
+            found_apps = await self.host.bridge.applications_list(self.args.filters)
+        except Exception as e:
+            self.disp(f"can't get applications list: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(found_apps)
+            self.host.quit()
+
+
+class Start(base.CommandBase):
+    """Start an application"""
+
+    def __init__(self, host):
+        super(Start, self).__init__(
+            host, "start", use_profile=False, help=_("start an application")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "name",
+            help=_("name of the application to start"),
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.application_start(
+                self.args.name,
+                "",
+            )
+        except Exception as e:
+            self.disp(f"can't start {self.args.name}: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Stop(base.CommandBase):
+
+    def __init__(self, host):
+        super(Stop, self).__init__(
+            host, "stop", use_profile=False, help=_("stop a running application")
+        )
+
+    def add_parser_options(self):
+        id_group = self.parser.add_mutually_exclusive_group(required=True)
+        id_group.add_argument(
+            "name",
+            nargs="?",
+            help=_("name of the application to stop"),
+        )
+        id_group.add_argument(
+            "-i",
+            "--id",
+            help=_("identifier of the instance to stop"),
+        )
+
+    async def start(self):
+        try:
+            if self.args.name is not None:
+                args = [self.args.name, "name"]
+            else:
+                args = [self.args.id, "instance"]
+            await self.host.bridge.application_stop(
+                *args,
+                "",
+            )
+        except Exception as e:
+            if self.args.name is not None:
+                self.disp(
+                    f"can't stop application {self.args.name!r}: {e}", error=True)
+            else:
+                self.disp(
+                    f"can't stop application instance with id {self.args.id!r}: {e}",
+                    error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Exposed(base.CommandBase):
+
+    def __init__(self, host):
+        super(Exposed, self).__init__(
+            host, "exposed", use_profile=False, use_output=C.OUTPUT_DICT,
+            help=_("show data exposed by a running application")
+        )
+
+    def add_parser_options(self):
+        id_group = self.parser.add_mutually_exclusive_group(required=True)
+        id_group.add_argument(
+            "name",
+            nargs="?",
+            help=_("name of the application to check"),
+        )
+        id_group.add_argument(
+            "-i",
+            "--id",
+            help=_("identifier of the instance to check"),
+        )
+
+    async def start(self):
+        try:
+            if self.args.name is not None:
+                args = [self.args.name, "name"]
+            else:
+                args = [self.args.id, "instance"]
+            exposed_data_raw = await self.host.bridge.application_exposed_get(
+                *args,
+                "",
+            )
+        except Exception as e:
+            if self.args.name is not None:
+                self.disp(
+                    f"can't get values exposed from application {self.args.name!r}: {e}",
+                    error=True)
+            else:
+                self.disp(
+                    f"can't values exposed from  application instance with id {self.args.id!r}: {e}",
+                    error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            exposed_data = data_format.deserialise(exposed_data_raw)
+            await self.output(exposed_data)
+            self.host.quit()
+
+
+class Application(base.CommandBase):
+    subcommands = (List, Start, Stop, Exposed)
+
+    def __init__(self, host):
+        super(Application, self).__init__(
+            host, "application", use_profile=False, help=_("manage applications"),
+            aliases=['app'],
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_avatar.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+
+import os
+import os.path
+import asyncio
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.tools import config
+from libervia.backend.tools.common import data_format
+
+
+__commands__ = ["Avatar"]
+DISPLAY_CMD = ["xdg-open", "xv", "display", "gwenview", "showtell"]
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        super(Get, self).__init__(
+            host, "get", use_verbose=True, help=_("retrieve avatar of an entity")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--no-cache", action="store_true", help=_("do no use cached values")
+        )
+        self.parser.add_argument(
+            "-s", "--show", action="store_true", help=_("show avatar")
+        )
+        self.parser.add_argument("jid", nargs='?', default='', help=_("entity"))
+
+    async def show_image(self, path):
+        sat_conf = config.parse_main_conf()
+        cmd = config.config_get(sat_conf, C.CONFIG_SECTION, "image_cmd")
+        cmds = [cmd] + DISPLAY_CMD if cmd else DISPLAY_CMD
+        for cmd in cmds:
+            try:
+                process = await asyncio.create_subprocess_exec(cmd, path)
+                ret = await process.wait()
+            except OSError:
+                continue
+
+            if ret in (0, 2):
+                # we can get exit code 2 with display when stopping it with C-c
+                break
+        else:
+            # didn't worked with commands, we try our luck with webbrowser
+            # in some cases, webbrowser can actually open the associated display program.
+            # Note that this may be possibly blocking, depending on the platform and
+            # available browser
+            import webbrowser
+
+            webbrowser.open(path)
+
+    async def start(self):
+        try:
+            avatar_data_raw = await self.host.bridge.avatar_get(
+                self.args.jid,
+                not self.args.no_cache,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't retrieve avatar: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        avatar_data = data_format.deserialise(avatar_data_raw, type_check=None)
+
+        if not avatar_data:
+            self.disp(_("No avatar found."), 1)
+            self.host.quit(C.EXIT_NOT_FOUND)
+
+        avatar_path = avatar_data['path']
+
+        self.disp(avatar_path)
+        if self.args.show:
+            await self.show_image(avatar_path)
+
+        self.host.quit()
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        super(Set, self).__init__(
+            host, "set", use_verbose=True,
+            help=_("set avatar of the profile or an entity")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j", "--jid", default='', help=_("entity whose avatar must be changed"))
+        self.parser.add_argument(
+            "image_path", type=str, help=_("path to the image to upload")
+        )
+
+    async def start(self):
+        path = self.args.image_path
+        if not os.path.exists(path):
+            self.disp(_("file {path} doesn't exist!").format(path=repr(path)), error=True)
+            self.host.quit(C.EXIT_BAD_ARG)
+        path = os.path.abspath(path)
+        try:
+            await self.host.bridge.avatar_set(path, self.args.jid, self.profile)
+        except Exception as e:
+            self.disp(f"can't set avatar: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("avatar has been set"), 1)
+            self.host.quit()
+
+
+class Avatar(base.CommandBase):
+    subcommands = (Get, Set)
+
+    def __init__(self, host):
+        super(Avatar, self).__init__(
+            host, "avatar", use_profile=False, help=_("avatar uploading/retrieving")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_blocking.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+
+import json
+import os
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.frontends.jp import common
+from libervia.frontends.jp.constants import Const as C
+from . import base
+
+__commands__ = ["Blocking"]
+
+
+class List(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "list",
+            use_output=C.OUTPUT_LIST,
+            help=_("list blocked entities"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            blocked_jids = await self.host.bridge.blocking_list(
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get blocked entities: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(blocked_jids)
+            self.host.quit(C.EXIT_OK)
+
+
+class Block(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "block",
+            help=_("block one or more entities"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "entities",
+            nargs="+",
+            metavar="JID",
+            help=_("JIDs of entities to block"),
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.blocking_block(
+                self.args.entities,
+                self.profile
+            )
+        except Exception as e:
+            self.disp(f"can't block entities: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class Unblock(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "unblock",
+            help=_("unblock one or more entities"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "entities",
+            nargs="+",
+            metavar="JID",
+            help=_("JIDs of entities to unblock"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_('when "all" is used, unblock all entities without confirmation'),
+        )
+
+    async def start(self):
+        if self.args.entities == ["all"]:
+            if not self.args.force:
+                await self.host.confirm_or_quit(
+                    _("All entities will be unblocked, are you sure"),
+                    _("unblock cancelled")
+                )
+            self.args.entities.clear()
+        elif self.args.force:
+            self.parser.error(_('--force is only allowed when "all" is used as target'))
+
+        try:
+            await self.host.bridge.blocking_unblock(
+                self.args.entities,
+                self.profile
+            )
+        except Exception as e:
+            self.disp(f"can't unblock entities: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class Blocking(base.CommandBase):
+    subcommands = (List, Block, Unblock)
+
+    def __init__(self, host):
+        super().__init__(
+            host, "blocking", use_profile=False, help=_("entities blocking")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_blog.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1219 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+
+import asyncio
+from asyncio.subprocess import DEVNULL
+from configparser import NoOptionError, NoSectionError
+import json
+import os
+import os.path
+from pathlib import Path
+import re
+import subprocess
+import sys
+import tempfile
+from urllib.parse import urlparse
+
+from libervia.backend.core.i18n import _
+from libervia.backend.tools import config
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.frontends.jp import common
+from libervia.frontends.jp.constants import Const as C
+
+from . import base, cmd_pubsub
+
+__commands__ = ["Blog"]
+
+SYNTAX_XHTML = "xhtml"
+# extensions to use with known syntaxes
+SYNTAX_EXT = {
+    # FIXME: default syntax doesn't sounds needed, there should always be a syntax set
+    #        by the plugin.
+    "": "txt",  # used when the syntax is not found
+    SYNTAX_XHTML: "xhtml",
+    "markdown": "md",
+}
+
+
+CONF_SYNTAX_EXT = "syntax_ext_dict"
+BLOG_TMP_DIR = "blog"
+# key to remove from metadata tmp file if they exist
+KEY_TO_REMOVE_METADATA = (
+    "id",
+    "content",
+    "content_xhtml",
+    "comments_node",
+    "comments_service",
+    "updated",
+)
+
+URL_REDIRECT_PREFIX = "url_redirect_"
+AIONOTIFY_INSTALL = '"pip install aionotify"'
+MB_KEYS = (
+    "id",
+    "url",
+    "atom_id",
+    "updated",
+    "published",
+    "language",
+    "comments",  # this key is used for all comments* keys
+    "tags",  # this key is used for all tag* keys
+    "author",
+    "author_jid",
+    "author_email",
+    "author_jid_verified",
+    "content",
+    "content_xhtml",
+    "title",
+    "title_xhtml",
+    "extra"
+)
+OUTPUT_OPT_NO_HEADER = "no-header"
+RE_ATTACHMENT_METADATA = re.compile(r"^(?P<key>[a-z_]+)=(?P<value>.*)")
+ALLOWER_ATTACH_MD_KEY = ("desc", "media_type", "external")
+
+
+async def guess_syntax_from_path(host, sat_conf, path):
+    """Return syntax guessed according to filename extension
+
+    @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
+    @param path(str): path to the content file
+    @return(unicode): syntax to use
+    """
+    # we first try to guess syntax with extension
+    ext = os.path.splitext(path)[1][1:]  # we get extension without the '.'
+    if ext:
+        for k, v in SYNTAX_EXT.items():
+            if k and ext == v:
+                return k
+
+                # if not found, we use current syntax
+    return await host.bridge.param_get_a("Syntax", "Composition", "value", host.profile)
+
+
+class BlogPublishCommon:
+    """handle common option for publising commands (Set and Edit)"""
+
+    async def get_current_syntax(self):
+        """Retrieve current_syntax
+
+        Use default syntax if --syntax has not been used, else check given syntax.
+        Will set self.default_syntax_used to True if default syntax has been used
+        """
+        if self.args.syntax is None:
+            self.default_syntax_used = True
+            return await self.host.bridge.param_get_a(
+                "Syntax", "Composition", "value", self.profile
+            )
+        else:
+            self.default_syntax_used = False
+            try:
+                syntax = await self.host.bridge.syntax_get(self.args.syntax)
+                self.current_syntax = self.args.syntax = syntax
+            except Exception as e:
+                if e.classname == "NotFound":
+                    self.parser.error(
+                        _("unknown syntax requested ({syntax})").format(
+                            syntax=self.args.syntax
+                        )
+                    )
+                else:
+                    raise e
+        return self.args.syntax
+
+    def add_parser_options(self):
+        self.parser.add_argument("-T", "--title", help=_("title of the item"))
+        self.parser.add_argument(
+            "-t",
+            "--tag",
+            action="append",
+            help=_("tag (category) of your item"),
+        )
+        self.parser.add_argument(
+            "-l",
+            "--language",
+            help=_("language of the item (ISO 639 code)"),
+        )
+
+        self.parser.add_argument(
+            "-a",
+            "--attachment",
+            dest="attachments",
+            nargs="+",
+            help=_(
+                "attachment in the form URL [metadata_name=value]"
+            )
+        )
+
+        comments_group = self.parser.add_mutually_exclusive_group()
+        comments_group.add_argument(
+            "-C",
+            "--comments",
+            action="store_const",
+            const=True,
+            dest="comments",
+            help=_(
+                "enable comments (default: comments not enabled except if they "
+                "already exist)"
+            ),
+        )
+        comments_group.add_argument(
+            "--no-comments",
+            action="store_const",
+            const=False,
+            dest="comments",
+            help=_("disable comments (will remove comments node if it exist)"),
+        )
+
+        self.parser.add_argument(
+            "-S",
+            "--syntax",
+            help=_("syntax to use (default: get profile's default syntax)"),
+        )
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("end-to-end encrypt the blog post")
+        )
+        self.parser.add_argument(
+            "--encrypt-for",
+            metavar="JID",
+            action="append",
+            help=_("encrypt a single item for")
+        )
+        self.parser.add_argument(
+            "-X",
+            "--sign",
+            action="store_true",
+            help=_("cryptographically sign the blog post")
+        )
+
+    async def set_mb_data_content(self, content, mb_data):
+        if self.default_syntax_used:
+            # default syntax has been used
+            mb_data["content_rich"] = content
+        elif self.current_syntax == SYNTAX_XHTML:
+            mb_data["content_xhtml"] = content
+        else:
+            mb_data["content_xhtml"] = await self.host.bridge.syntax_convert(
+                content, self.current_syntax, SYNTAX_XHTML, False, self.profile
+            )
+
+    def handle_attachments(self, mb_data: dict) -> None:
+        """Check, validate and add attachments to mb_data"""
+        if self.args.attachments:
+            attachments = []
+            attachment = {}
+            for arg in self.args.attachments:
+                m = RE_ATTACHMENT_METADATA.match(arg)
+                if m is None:
+                    # we should have an URL
+                    url_parsed = urlparse(arg)
+                    if url_parsed.scheme not in ("http", "https"):
+                        self.parser.error(
+                            "invalid URL in --attachment (only http(s) scheme is "
+                            f" accepted): {arg}"
+                        )
+                    if attachment:
+                        # if we hae a new URL, we have a new attachment
+                        attachments.append(attachment)
+                        attachment = {}
+                    attachment["url"] = arg
+                else:
+                    # we should have a metadata
+                    if "url" not in attachment:
+                        self.parser.error(
+                            "you must to specify an URL before any metadata in "
+                            "--attachment"
+                        )
+                    key = m.group("key")
+                    if key not in ALLOWER_ATTACH_MD_KEY:
+                        self.parser.error(
+                            f"invalid metadata key in --attachment: {key!r}"
+                        )
+                    value = m.group("value").strip()
+                    if key == "external":
+                        if not value:
+                            value=True
+                        else:
+                            value = C.bool(value)
+                    attachment[key] = value
+            if attachment:
+                attachments.append(attachment)
+            if attachments:
+                mb_data.setdefault("extra", {})["attachments"] = attachments
+
+    def set_mb_data_from_args(self, mb_data):
+        """set microblog metadata according to command line options
+
+        if metadata already exist, it will be overwritten
+        """
+        if self.args.comments is not None:
+            mb_data["allow_comments"] = self.args.comments
+        if self.args.tag:
+            mb_data["tags"] = self.args.tag
+        if self.args.title is not None:
+            mb_data["title"] = self.args.title
+        if self.args.language is not None:
+            mb_data["language"] = self.args.language
+        if self.args.encrypt:
+            mb_data["encrypted"] = True
+        if self.args.sign:
+            mb_data["signed"] = True
+        if self.args.encrypt_for:
+            mb_data["encrypted_for"] = {"targets": self.args.encrypt_for}
+        self.handle_attachments(mb_data)
+
+
+class Set(base.CommandBase, BlogPublishCommon):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("publish a new blog item or update an existing one"),
+        )
+        BlogPublishCommon.__init__(self)
+
+    def add_parser_options(self):
+        BlogPublishCommon.add_parser_options(self)
+
+    async def start(self):
+        self.current_syntax = await self.get_current_syntax()
+        self.pubsub_item = self.args.item
+        mb_data = {}
+        self.set_mb_data_from_args(mb_data)
+        if self.pubsub_item:
+            mb_data["id"] = self.pubsub_item
+        content = sys.stdin.read()
+        await self.set_mb_data_content(content, mb_data)
+
+        try:
+            item_id = await self.host.bridge.mb_send(
+                self.args.service,
+                self.args.node,
+                data_format.serialise(mb_data),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't send item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(f"Item published with ID {item_id}")
+            self.host.quit(C.EXIT_OK)
+
+
+class Get(base.CommandBase):
+    TEMPLATE = "blog/articles.html"
+
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output, "fancy": self.fancy_output}
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_verbose=True,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS, C.CACHE},
+            use_output=C.OUTPUT_COMPLEX,
+            extra_outputs=extra_outputs,
+            help=_("get blog item(s)"),
+        )
+
+    def add_parser_options(self):
+        #  TODO: a key(s) argument to select keys to display
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            action="append",
+            dest="keys",
+            help=_("microblog data key(s) to display (default: depend of verbosity)"),
+        )
+        # TODO: add MAM filters
+
+    def template_data_mapping(self, data):
+        items, blog_items = data
+        blog_items["items"] = items
+        return {"blog_items": blog_items}
+
+    def format_comments(self, item, keys):
+        lines = []
+        for data in item.get("comments", []):
+            lines.append(data["uri"])
+            for k in ("node", "service"):
+                if OUTPUT_OPT_NO_HEADER in self.args.output_opts:
+                    header = ""
+                else:
+                    header = f"{C.A_HEADER}comments_{k}: {A.RESET}"
+                lines.append(header + data[k])
+        return "\n".join(lines)
+
+    def format_tags(self, item, keys):
+        tags = item.pop("tags", [])
+        return ", ".join(tags)
+
+    def format_updated(self, item, keys):
+        return common.format_time(item["updated"])
+
+    def format_published(self, item, keys):
+        return common.format_time(item["published"])
+
+    def format_url(self, item, keys):
+        return uri.build_xmpp_uri(
+            "pubsub",
+            subtype="microblog",
+            path=self.metadata["service"],
+            node=self.metadata["node"],
+            item=item["id"],
+        )
+
+    def get_keys(self):
+        """return keys to display according to verbosity or explicit key request"""
+        verbosity = self.args.verbose
+        if self.args.keys:
+            if not set(MB_KEYS).issuperset(self.args.keys):
+                self.disp(
+                    "following keys are invalid: {invalid}.\n"
+                    "Valid keys are: {valid}.".format(
+                        invalid=", ".join(set(self.args.keys).difference(MB_KEYS)),
+                        valid=", ".join(sorted(MB_KEYS)),
+                    ),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+            return self.args.keys
+        else:
+            if verbosity == 0:
+                return ("title", "content")
+            elif verbosity == 1:
+                return (
+                    "title",
+                    "tags",
+                    "author",
+                    "author_jid",
+                    "author_email",
+                    "author_jid_verified",
+                    "published",
+                    "updated",
+                    "content",
+                )
+            else:
+                return MB_KEYS
+
+    def default_output(self, data):
+        """simple key/value output"""
+        items, self.metadata = data
+        keys = self.get_keys()
+
+        #  k_cb use format_[key] methods for complex formattings
+        k_cb = {}
+        for k in keys:
+            try:
+                callback = getattr(self, "format_" + k)
+            except AttributeError:
+                pass
+            else:
+                k_cb[k] = callback
+        for idx, item in enumerate(items):
+            for k in keys:
+                if k not in item and k not in k_cb:
+                    continue
+                if OUTPUT_OPT_NO_HEADER in self.args.output_opts:
+                    header = ""
+                else:
+                    header = "{k_fmt}{key}:{k_fmt_e} {sep}".format(
+                        k_fmt=C.A_HEADER,
+                        key=k,
+                        k_fmt_e=A.RESET,
+                        sep="\n" if "content" in k else "",
+                    )
+                value = k_cb[k](item, keys) if k in k_cb else item[k]
+                if isinstance(value, bool):
+                    value = str(value).lower()
+                elif isinstance(value, dict):
+                    value = repr(value)
+                self.disp(header + (value or ""))
+                # we want a separation line after each item but the last one
+            if idx < len(items) - 1:
+                print("")
+
+    def fancy_output(self, data):
+        """display blog is a nice to read way
+
+        this output doesn't use keys filter
+        """
+        # thanks to http://stackoverflow.com/a/943921
+        rows, columns = list(map(int, os.popen("stty size", "r").read().split()))
+        items, metadata = data
+        verbosity = self.args.verbose
+        sep = A.color(A.FG_BLUE, columns * "▬")
+        if items:
+            print(("\n" + sep + "\n"))
+
+        for idx, item in enumerate(items):
+            title = item.get("title")
+            if verbosity > 0:
+                author = item["author"]
+                published, updated = item["published"], item.get("updated")
+            else:
+                author = published = updated = None
+            if verbosity > 1:
+                tags = item.pop("tags", [])
+            else:
+                tags = None
+            content = item.get("content")
+
+            if title:
+                print((A.color(A.BOLD, A.FG_CYAN, item["title"])))
+            meta = []
+            if author:
+                meta.append(A.color(A.FG_YELLOW, author))
+            if published:
+                meta.append(A.color(A.FG_YELLOW, "on ", common.format_time(published)))
+            if updated != published:
+                meta.append(
+                    A.color(A.FG_YELLOW, "(updated on ", common.format_time(updated), ")")
+                )
+            print((" ".join(meta)))
+            if tags:
+                print((A.color(A.FG_MAGENTA, ", ".join(tags))))
+            if (title or tags) and content:
+                print("")
+            if content:
+                self.disp(content)
+
+            print(("\n" + sep + "\n"))
+
+    async def start(self):
+        try:
+            mb_data = data_format.deserialise(
+                await self.host.bridge.mb_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    self.get_pubsub_extra(),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't get blog items: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            items = mb_data.pop("items")
+            await self.output((items, mb_data))
+            self.host.quit(C.EXIT_OK)
+
+
+class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "edit",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            use_draft=True,
+            use_verbose=True,
+            help=_("edit an existing or new blog post"),
+        )
+        BlogPublishCommon.__init__(self)
+        common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
+
+    def add_parser_options(self):
+        BlogPublishCommon.add_parser_options(self)
+        self.parser.add_argument(
+            "-P",
+            "--preview",
+            action="store_true",
+            help=_("launch a blog preview in parallel"),
+        )
+        self.parser.add_argument(
+            "--no-publish",
+            action="store_true",
+            help=_('add "publish: False" to metadata'),
+        )
+
+    def build_metadata_file(self, content_file_path, mb_data=None):
+        """Build a metadata file using json
+
+        The file is named after content_file_path, with extension replaced by
+        _metadata.json
+        @param content_file_path(str): path to the temporary file which will contain the
+            body
+        @param mb_data(dict, None): microblog metadata (for existing items)
+        @return (tuple[dict, Path]): merged metadata put originaly in metadata file
+            and path to temporary metadata file
+        """
+        # we first construct metadata from edited item ones and CLI argumments
+        # or re-use the existing one if it exists
+        meta_file_path = content_file_path.with_name(
+            content_file_path.stem + common.METADATA_SUFF
+        )
+        if meta_file_path.exists():
+            self.disp("Metadata file already exists, we re-use it")
+            try:
+                with meta_file_path.open("rb") as f:
+                    mb_data = json.load(f)
+            except (OSError, IOError, ValueError) as e:
+                self.disp(
+                    f"Can't read existing metadata file at {meta_file_path}, "
+                    f"aborting: {e}",
+                    error=True,
+                )
+                self.host.quit(1)
+        else:
+            mb_data = {} if mb_data is None else mb_data.copy()
+
+            # in all cases, we want to remove unwanted keys
+        for key in KEY_TO_REMOVE_METADATA:
+            try:
+                del mb_data[key]
+            except KeyError:
+                pass
+                # and override metadata with command-line arguments
+        self.set_mb_data_from_args(mb_data)
+
+        if self.args.no_publish:
+            mb_data["publish"] = False
+
+            # then we create the file and write metadata there, as JSON dict
+            # XXX: if we port jp one day on Windows, O_BINARY may need to be added here
+        with os.fdopen(
+            os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), "w+b"
+        ) as f:
+            # we need to use an intermediate unicode buffer to write to the file
+            # unicode without escaping characters
+            unicode_dump = json.dumps(
+                mb_data,
+                ensure_ascii=False,
+                indent=4,
+                separators=(",", ": "),
+                sort_keys=True,
+            )
+            f.write(unicode_dump.encode("utf-8"))
+
+        return mb_data, meta_file_path
+
+    async def edit(self, content_file_path, content_file_obj, mb_data=None):
+        """Edit the file contening the content using editor, and publish it"""
+        # we first create metadata file
+        meta_ori, meta_file_path = self.build_metadata_file(content_file_path, mb_data)
+
+        coroutines = []
+
+        # do we need a preview ?
+        if self.args.preview:
+            self.disp("Preview requested, launching it", 1)
+            # we redirect outputs to /dev/null to avoid console pollution in editor
+            # if user wants to see messages, (s)he can call "blog preview" directly
+            coroutines.append(
+                asyncio.create_subprocess_exec(
+                    sys.argv[0],
+                    "blog",
+                    "preview",
+                    "--inotify",
+                    "true",
+                    "-p",
+                    self.profile,
+                    str(content_file_path),
+                    stdout=DEVNULL,
+                    stderr=DEVNULL,
+                )
+            )
+
+            # we launch editor
+        coroutines.append(
+            self.run_editor(
+                "blog_editor_args",
+                content_file_path,
+                content_file_obj,
+                meta_file_path=meta_file_path,
+                meta_ori=meta_ori,
+            )
+        )
+
+        await asyncio.gather(*coroutines)
+
+    async def publish(self, content, mb_data):
+        await self.set_mb_data_content(content, mb_data)
+
+        if self.pubsub_item:
+            mb_data["id"] = self.pubsub_item
+
+        mb_data = data_format.serialise(mb_data)
+
+        await self.host.bridge.mb_send(
+            self.pubsub_service, self.pubsub_node, mb_data, self.profile
+        )
+        self.disp("Blog item published")
+
+    def get_tmp_suff(self):
+        # we get current syntax to determine file extension
+        return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[""])
+
+    async def get_item_data(self, service, node, item):
+        items = [item] if item else []
+
+        mb_data = data_format.deserialise(
+            await self.host.bridge.mb_get(
+                service, node, 1, items, data_format.serialise({}), self.profile
+            )
+        )
+        item = mb_data["items"][0]
+
+        try:
+            content = item["content_xhtml"]
+        except KeyError:
+            content = item["content"]
+            if content:
+                content = await self.host.bridge.syntax_convert(
+                    content, "text", SYNTAX_XHTML, False, self.profile
+                )
+
+        if content and self.current_syntax != SYNTAX_XHTML:
+            content = await self.host.bridge.syntax_convert(
+                content, SYNTAX_XHTML, self.current_syntax, False, self.profile
+            )
+
+        if content and self.current_syntax == SYNTAX_XHTML:
+            content = content.strip()
+            if not content.startswith("<div>"):
+                content = "<div>" + content + "</div>"
+            try:
+                from lxml import etree
+            except ImportError:
+                self.disp(_("You need lxml to edit pretty XHTML"))
+            else:
+                parser = etree.XMLParser(remove_blank_text=True)
+                root = etree.fromstring(content, parser)
+                content = etree.tostring(root, encoding=str, pretty_print=True)
+
+        return content, item, item["id"]
+
+    async def start(self):
+        # if there are user defined extension, we use them
+        SYNTAX_EXT.update(
+            config.config_get(self.sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
+        )
+        self.current_syntax = await self.get_current_syntax()
+
+        (
+            self.pubsub_service,
+            self.pubsub_node,
+            self.pubsub_item,
+            content_file_path,
+            content_file_obj,
+            mb_data,
+        ) = await self.get_item_path()
+
+        await self.edit(content_file_path, content_file_obj, mb_data=mb_data)
+        self.host.quit()
+
+
+class Rename(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "rename",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("rename an blog item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("new_id", help=_("new item id to use"))
+
+    async def start(self):
+        try:
+            await self.host.bridge.mb_rename(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.new_id,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't rename item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("Item renamed")
+            self.host.quit(C.EXIT_OK)
+
+
+class Repeat(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "repeat",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("repeat (re-publish) a blog item"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            repeat_id = await self.host.bridge.mb_repeat(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't repeat item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if repeat_id:
+                self.disp(f"Item repeated at ID {str(repeat_id)!r}")
+            else:
+                self.disp("Item repeated")
+            self.host.quit(C.EXIT_OK)
+
+
+class Preview(base.CommandBase, common.BaseEdit):
+    # TODO: need to be rewritten with template output
+
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "preview", use_verbose=True, help=_("preview a blog content")
+        )
+        common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--inotify",
+            type=str,
+            choices=("auto", "true", "false"),
+            default="auto",
+            help=_("use inotify to handle preview"),
+        )
+        self.parser.add_argument(
+            "file",
+            nargs="?",
+            default="current",
+            help=_("path to the content file"),
+        )
+
+    async def show_preview(self):
+        # we implement show_preview here so we don't have to import webbrowser and urllib
+        # when preview is not used
+        url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
+        self.webbrowser.open_new_tab(url)
+
+    async def _launch_preview_ext(self, cmd_line, opt_name):
+        url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
+        args = common.parse_args(
+            self.host, cmd_line, url=url, preview_file=self.preview_file_path
+        )
+        if not args:
+            self.disp(
+                'Couln\'t find command in "{name}", abording'.format(name=opt_name),
+                error=True,
+            )
+            self.host.quit(1)
+        subprocess.Popen(args)
+
+    async def open_preview_ext(self):
+        await self._launch_preview_ext(self.open_cb_cmd, "blog_preview_open_cmd")
+
+    async def update_preview_ext(self):
+        await self._launch_preview_ext(self.update_cb_cmd, "blog_preview_update_cmd")
+
+    async def update_content(self):
+        with self.content_file_path.open("rb") as f:
+            content = f.read().decode("utf-8-sig")
+            if content and self.syntax != SYNTAX_XHTML:
+                # we use safe=True because we want to have a preview as close as possible
+                # to what the people will see
+                content = await self.host.bridge.syntax_convert(
+                    content, self.syntax, SYNTAX_XHTML, True, self.profile
+                )
+
+        xhtml = (
+            f'<html xmlns="http://www.w3.org/1999/xhtml">'
+            f'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />'
+            f"</head>"
+            f"<body>{content}</body>"
+            f"</html>"
+        )
+
+        with open(self.preview_file_path, "wb") as f:
+            f.write(xhtml.encode("utf-8"))
+
+    async def start(self):
+        import webbrowser
+        import urllib.request, urllib.parse, urllib.error
+
+        self.webbrowser, self.urllib = webbrowser, urllib
+
+        if self.args.inotify != "false":
+            try:
+                import aionotify
+
+            except ImportError:
+                if self.args.inotify == "auto":
+                    aionotify = None
+                    self.disp(
+                        f"aionotify module not found, deactivating feature. You can "
+                        f"install it with {AIONOTIFY_INSTALL}"
+                    )
+                else:
+                    self.disp(
+                        f"aioinotify not found, can't activate the feature! Please "
+                        f"install it with {AIONOTIFY_INSTALL}",
+                        error=True,
+                    )
+                    self.host.quit(1)
+        else:
+            aionotify = None
+
+        sat_conf = self.sat_conf
+        SYNTAX_EXT.update(
+            config.config_get(sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
+        )
+
+        try:
+            self.open_cb_cmd = config.config_get(
+                sat_conf, C.CONFIG_SECTION, "blog_preview_open_cmd", Exception
+            )
+        except (NoOptionError, NoSectionError):
+            self.open_cb_cmd = None
+            open_cb = self.show_preview
+        else:
+            open_cb = self.open_preview_ext
+
+        self.update_cb_cmd = config.config_get(
+            sat_conf, C.CONFIG_SECTION, "blog_preview_update_cmd", self.open_cb_cmd
+        )
+        if self.update_cb_cmd is None:
+            update_cb = self.show_preview
+        else:
+            update_cb = self.update_preview_ext
+
+            # which file do we need to edit?
+        if self.args.file == "current":
+            self.content_file_path = self.get_current_file(self.profile)
+        else:
+            try:
+                self.content_file_path = Path(self.args.file).resolve(strict=True)
+            except FileNotFoundError:
+                self.disp(_('File "{file}" doesn\'t exist!').format(file=self.args.file))
+                self.host.quit(C.EXIT_NOT_FOUND)
+
+        self.syntax = await guess_syntax_from_path(
+            self.host, sat_conf, self.content_file_path
+        )
+
+        # at this point the syntax is converted, we can display the preview
+        preview_file = tempfile.NamedTemporaryFile(suffix=".xhtml", delete=False)
+        self.preview_file_path = preview_file.name
+        preview_file.close()
+        await self.update_content()
+
+        if aionotify is None:
+            # XXX: we don't delete file automatically because browser needs it
+            #      (and webbrowser.open can return before it is read)
+            self.disp(
+                f"temporary file created at {self.preview_file_path}\nthis file will NOT "
+                f"BE DELETED AUTOMATICALLY, please delete it yourself when you have "
+                f"finished"
+            )
+            await open_cb()
+        else:
+            await open_cb()
+            watcher = aionotify.Watcher()
+            watcher_kwargs = {
+                # Watcher don't accept Path so we convert to string
+                "path": str(self.content_file_path),
+                "alias": "content_file",
+                "flags": aionotify.Flags.CLOSE_WRITE
+                | aionotify.Flags.DELETE_SELF
+                | aionotify.Flags.MOVE_SELF,
+            }
+            watcher.watch(**watcher_kwargs)
+
+            loop = asyncio.get_event_loop()
+            await watcher.setup(loop)
+
+            try:
+                while True:
+                    event = await watcher.get_event()
+                    self.disp("Content updated", 1)
+                    if event.flags & (
+                        aionotify.Flags.DELETE_SELF | aionotify.Flags.MOVE_SELF
+                    ):
+                        self.disp(
+                            "DELETE/MOVE event catched, changing the watch",
+                            2,
+                        )
+                        try:
+                            watcher.unwatch("content_file")
+                        except IOError as e:
+                            self.disp(
+                                f"Can't remove the watch: {e}",
+                                2,
+                            )
+                        watcher = aionotify.Watcher()
+                        watcher.watch(**watcher_kwargs)
+                        try:
+                            await watcher.setup(loop)
+                        except OSError:
+                            # if the new file is not here yet we can have an error
+                            # as a workaround, we do a little rest and try again
+                            await asyncio.sleep(1)
+                            await watcher.setup(loop)
+                    await self.update_content()
+                    await update_cb()
+            except FileNotFoundError:
+                self.disp("The file seems to have been deleted.", error=True)
+                self.host.quit(C.EXIT_NOT_FOUND)
+            finally:
+                os.unlink(self.preview_file_path)
+                try:
+                    watcher.unwatch("content_file")
+                except IOError as e:
+                    self.disp(
+                        f"Can't remove the watch: {e}",
+                        2,
+                    )
+
+
+class Import(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "import",
+            use_pubsub=True,
+            use_progress=True,
+            help=_("import an external blog"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "importer",
+            nargs="?",
+            help=_("importer name, nothing to display importers list"),
+        )
+        self.parser.add_argument("--host", help=_("original blog host"))
+        self.parser.add_argument(
+            "--no-images-upload",
+            action="store_true",
+            help=_("do *NOT* upload images (default: do upload images)"),
+        )
+        self.parser.add_argument(
+            "--upload-ignore-host",
+            help=_("do not upload images from this host (default: upload all images)"),
+        )
+        self.parser.add_argument(
+            "--ignore-tls-errors",
+            action="store_true",
+            help=_("ignore invalide TLS certificate for uploads"),
+        )
+        self.parser.add_argument(
+            "-o",
+            "--option",
+            action="append",
+            nargs=2,
+            default=[],
+            metavar=("NAME", "VALUE"),
+            help=_("importer specific options (see importer description)"),
+        )
+        self.parser.add_argument(
+            "location",
+            nargs="?",
+            help=_(
+                "importer data location (see importer description), nothing to show "
+                "importer description"
+            ),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("Blog upload started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("Blog uploaded successfully"), 2)
+        redirections = {
+            k[len(URL_REDIRECT_PREFIX) :]: v
+            for k, v in metadata.items()
+            if k.startswith(URL_REDIRECT_PREFIX)
+        }
+        if redirections:
+            conf = "\n".join(
+                [
+                    "url_redirections_dict = {}".format(
+                        # we need to add ' ' before each new line
+                        # and to double each '%' for ConfigParser
+                        "\n ".join(
+                            json.dumps(redirections, indent=1, separators=(",", ": "))
+                            .replace("%", "%%")
+                            .split("\n")
+                        )
+                    ),
+                ]
+            )
+            self.disp(
+                _(
+                    "\nTo redirect old URLs to new ones, put the following lines in your"
+                    " sat.conf file, in [libervia] section:\n\n{conf}"
+                ).format(conf=conf)
+            )
+
+    async def on_progress_error(self, error_msg):
+        self.disp(
+            _("Error while uploading blog: {error_msg}").format(error_msg=error_msg),
+            error=True,
+        )
+
+    async def start(self):
+        if self.args.location is None:
+            for name in ("option", "service", "no_images_upload"):
+                if getattr(self.args, name):
+                    self.parser.error(
+                        _(
+                            "{name} argument can't be used without location argument"
+                        ).format(name=name)
+                    )
+            if self.args.importer is None:
+                self.disp(
+                    "\n".join(
+                        [
+                            f"{name}: {desc}"
+                            for name, desc in await self.host.bridge.blogImportList()
+                        ]
+                    )
+                )
+            else:
+                try:
+                    short_desc, long_desc = await self.host.bridge.blogImportDesc(
+                        self.args.importer
+                    )
+                except Exception as e:
+                    msg = [l for l in str(e).split("\n") if l][
+                        -1
+                    ]  # we only keep the last line
+                    self.disp(msg)
+                    self.host.quit(1)
+                else:
+                    self.disp(f"{self.args.importer}: {short_desc}\n\n{long_desc}")
+            self.host.quit()
+        else:
+            # we have a location, an import is requested
+            options = {key: value for key, value in self.args.option}
+            if self.args.host:
+                options["host"] = self.args.host
+            if self.args.ignore_tls_errors:
+                options["ignore_tls_errors"] = C.BOOL_TRUE
+            if self.args.no_images_upload:
+                options["upload_images"] = C.BOOL_FALSE
+                if self.args.upload_ignore_host:
+                    self.parser.error(
+                        "upload-ignore-host option can't be used when no-images-upload "
+                        "is set"
+                    )
+            elif self.args.upload_ignore_host:
+                options["upload_ignore_host"] = self.args.upload_ignore_host
+
+            try:
+                progress_id = await self.host.bridge.blogImport(
+                    self.args.importer,
+                    self.args.location,
+                    options,
+                    self.args.service,
+                    self.args.node,
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(
+                    _("Error while trying to import a blog: {e}").format(e=e),
+                    error=True,
+                )
+                self.host.quit(1)
+            else:
+                await self.set_progress_id(progress_id)
+
+
+class AttachmentGet(cmd_pubsub.AttachmentGet):
+
+    def __init__(self, host):
+        super().__init__(host)
+        self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
+
+
+    async def start(self):
+        if not self.args.node:
+            namespaces = await self.host.bridge.namespaces_get()
+            try:
+                ns_microblog = namespaces["microblog"]
+            except KeyError:
+                self.disp("XEP-0277 plugin is not loaded", error=True)
+                self.host.quit(C.EXIT_MISSING_FEATURE)
+            else:
+                self.args.node = ns_microblog
+        return await super().start()
+
+
+class AttachmentSet(cmd_pubsub.AttachmentSet):
+
+    def __init__(self, host):
+        super().__init__(host)
+        self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
+
+    async def start(self):
+        if not self.args.node:
+            namespaces = await self.host.bridge.namespaces_get()
+            try:
+                ns_microblog = namespaces["microblog"]
+            except KeyError:
+                self.disp("XEP-0277 plugin is not loaded", error=True)
+                self.host.quit(C.EXIT_MISSING_FEATURE)
+            else:
+                self.args.node = ns_microblog
+        return await super().start()
+
+
+class Attachments(base.CommandBase):
+    subcommands = (AttachmentGet, AttachmentSet)
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "attachments",
+            use_profile=False,
+            help=_("set or retrieve blog attachments"),
+        )
+
+
+class Blog(base.CommandBase):
+    subcommands = (Set, Get, Edit, Rename, Repeat, Preview, Import, Attachments)
+
+    def __init__(self, host):
+        super(Blog, self).__init__(
+            host, "blog", use_profile=False, help=_("blog/microblog management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_bookmarks.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,175 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+
+__commands__ = ["Bookmarks"]
+
+STORAGE_LOCATIONS = ("local", "private", "pubsub")
+TYPES = ("muc", "url")
+
+
+class BookmarksCommon(base.CommandBase):
+    """Class used to group common options of bookmarks subcommands"""
+
+    def add_parser_options(self, location_default="all"):
+        self.parser.add_argument(
+            "-l",
+            "--location",
+            type=str,
+            choices=(location_default,) + STORAGE_LOCATIONS,
+            default=location_default,
+            help=_("storage location (default: %(default)s)"),
+        )
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            type=str,
+            choices=TYPES,
+            default=TYPES[0],
+            help=_("bookmarks type (default: %(default)s)"),
+        )
+
+
+class BookmarksList(BookmarksCommon):
+    def __init__(self, host):
+        super(BookmarksList, self).__init__(host, "list", help=_("list bookmarks"))
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.bookmarks_list(
+                self.args.type, self.args.location, self.host.profile
+            )
+        except Exception as e:
+            self.disp(f"can't get bookmarks list: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        mess = []
+        for location in STORAGE_LOCATIONS:
+            if not data[location]:
+                continue
+            loc_mess = []
+            loc_mess.append(f"{location}:")
+            book_mess = []
+            for book_link, book_data in list(data[location].items()):
+                name = book_data.get("name")
+                autojoin = book_data.get("autojoin", "false") == "true"
+                nick = book_data.get("nick")
+                book_mess.append(
+                    "\t%s[%s%s]%s"
+                    % (
+                        (name + " ") if name else "",
+                        book_link,
+                        " (%s)" % nick if nick else "",
+                        " (*)" if autojoin else "",
+                    )
+                )
+            loc_mess.append("\n".join(book_mess))
+            mess.append("\n".join(loc_mess))
+
+        print("\n\n".join(mess))
+        self.host.quit()
+
+
+class BookmarksRemove(BookmarksCommon):
+    def __init__(self, host):
+        super(BookmarksRemove, self).__init__(host, "remove", help=_("remove a bookmark"))
+
+    def add_parser_options(self):
+        super(BookmarksRemove, self).add_parser_options()
+        self.parser.add_argument(
+            "bookmark", help=_("jid (for muc bookmark) or url of to remove")
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("delete bookmark without confirmation"),
+        )
+
+    async def start(self):
+        if not self.args.force:
+            await self.host.confirm_or_quit(_("Are you sure to delete this bookmark?"))
+
+        try:
+            await self.host.bridge.bookmarks_remove(
+                self.args.type, self.args.bookmark, self.args.location, self.host.profile
+            )
+        except Exception as e:
+            self.disp(_("can't delete bookmark: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("bookmark deleted"))
+            self.host.quit()
+
+
+class BookmarksAdd(BookmarksCommon):
+    def __init__(self, host):
+        super(BookmarksAdd, self).__init__(host, "add", help=_("add a bookmark"))
+
+    def add_parser_options(self):
+        super(BookmarksAdd, self).add_parser_options(location_default="auto")
+        self.parser.add_argument(
+            "bookmark", help=_("jid (for muc bookmark) or url of to remove")
+        )
+        self.parser.add_argument("-n", "--name", help=_("bookmark name"))
+        muc_group = self.parser.add_argument_group(_("MUC specific options"))
+        muc_group.add_argument("-N", "--nick", help=_("nickname"))
+        muc_group.add_argument(
+            "-a",
+            "--autojoin",
+            action="store_true",
+            help=_("join room on profile connection"),
+        )
+
+    async def start(self):
+        if self.args.type == "url" and (self.args.autojoin or self.args.nick is not None):
+            self.parser.error(_("You can't use --autojoin or --nick with --type url"))
+        data = {}
+        if self.args.autojoin:
+            data["autojoin"] = "true"
+        if self.args.nick is not None:
+            data["nick"] = self.args.nick
+        if self.args.name is not None:
+            data["name"] = self.args.name
+        try:
+            await self.host.bridge.bookmarks_add(
+                self.args.type,
+                self.args.bookmark,
+                data,
+                self.args.location,
+                self.host.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't add bookmark: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("bookmark successfully added"))
+            self.host.quit()
+
+
+class Bookmarks(base.CommandBase):
+    subcommands = (BookmarksList, BookmarksRemove, BookmarksAdd)
+
+    def __init__(self, host):
+        super(Bookmarks, self).__init__(
+            host, "bookmarks", use_profile=False, help=_("manage bookmarks")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_debug.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+import json
+
+__commands__ = ["Debug"]
+
+
+class BridgeCommon(object):
+    def eval_args(self):
+        if self.args.arg:
+            try:
+                return eval("[{}]".format(",".join(self.args.arg)))
+            except SyntaxError as e:
+                self.disp(
+                    "Can't evaluate arguments: {mess}\n{text}\n{offset}^".format(
+                        mess=e, text=e.text, offset=" " * (e.offset - 1)
+                    ),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+        else:
+            return []
+
+
+class Method(base.CommandBase, BridgeCommon):
+    def __init__(self, host):
+        base.CommandBase.__init__(self, host, "method", help=_("call a bridge method"))
+        BridgeCommon.__init__(self)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "method", type=str, help=_("name of the method to execute")
+        )
+        self.parser.add_argument("arg", nargs="*", help=_("argument of the method"))
+
+    async def start(self):
+        method = getattr(self.host.bridge, self.args.method)
+        import inspect
+
+        argspec = inspect.getargspec(method)
+
+        kwargs = {}
+        if "profile_key" in argspec.args:
+            kwargs["profile_key"] = self.profile
+        elif "profile" in argspec.args:
+            kwargs["profile"] = self.profile
+
+        args = self.eval_args()
+
+        try:
+            ret = await method(
+                *args,
+                **kwargs,
+            )
+        except Exception as e:
+            self.disp(
+                _("Error while executing {method}: {e}").format(
+                    method=self.args.method, e=e
+                ),
+                error=True,
+            )
+            self.host.quit(C.EXIT_ERROR)
+        else:
+            if ret is not None:
+                self.disp(str(ret))
+            self.host.quit()
+
+
+class Signal(base.CommandBase, BridgeCommon):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "signal", help=_("send a fake signal from backend")
+        )
+        BridgeCommon.__init__(self)
+
+    def add_parser_options(self):
+        self.parser.add_argument("signal", type=str, help=_("name of the signal to send"))
+        self.parser.add_argument("arg", nargs="*", help=_("argument of the signal"))
+
+    async def start(self):
+        args = self.eval_args()
+        json_args = json.dumps(args)
+        # XXX: we use self.args.profile and not self.profile
+        #      because we want the raw profile_key (so plugin handle C.PROF_KEY_NONE)
+        try:
+            await self.host.bridge.debug_signal_fake(
+                self.args.signal, json_args, self.args.profile
+            )
+        except Exception as e:
+            self.disp(_("Can't send fake signal: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_ERROR)
+        else:
+            self.host.quit()
+
+
+class bridge(base.CommandBase):
+    subcommands = (Method, Signal)
+
+    def __init__(self, host):
+        super(bridge, self).__init__(
+            host, "bridge", use_profile=False, help=_("bridge s(t)imulation")
+        )
+
+
+class Monitor(base.CommandBase):
+    def __init__(self, host):
+        super(Monitor, self).__init__(
+            host,
+            "monitor",
+            use_verbose=True,
+            use_profile=False,
+            use_output=C.OUTPUT_XML,
+            help=_("monitor XML stream"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-d",
+            "--direction",
+            choices=("in", "out", "both"),
+            default="both",
+            help=_("stream direction filter"),
+        )
+
+    async def print_xml(self, direction, xml_data, profile):
+        if self.args.direction == "in" and direction != "IN":
+            return
+        if self.args.direction == "out" and direction != "OUT":
+            return
+        verbosity = self.host.verbosity
+        if not xml_data.strip():
+            if verbosity <= 2:
+                return
+            whiteping = True
+        else:
+            whiteping = False
+
+        if verbosity:
+            profile_disp = f" ({profile})" if verbosity > 1 else ""
+            if direction == "IN":
+                self.disp(
+                    A.color(
+                        A.BOLD, A.FG_YELLOW, "<<<===== IN ====", A.FG_WHITE, profile_disp
+                    )
+                )
+            else:
+                self.disp(
+                    A.color(
+                        A.BOLD, A.FG_CYAN, "==== OUT ====>>>", A.FG_WHITE, profile_disp
+                    )
+                )
+        if whiteping:
+            self.disp("[WHITESPACE PING]")
+        else:
+            try:
+                await self.output(xml_data)
+            except Exception:
+                #  initial stream is not valid XML,
+                # in this case we print directly to data
+                #  FIXME: we should test directly lxml.etree.XMLSyntaxError
+                #        but importing lxml directly here is not clean
+                #        should be wrapped in a custom Exception
+                self.disp(xml_data)
+                self.disp("")
+
+    async def start(self):
+        self.host.bridge.register_signal("xml_log", self.print_xml, "plugin")
+
+
+class Theme(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "theme", help=_("print colours used with your background")
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        print(f"background currently used: {A.BOLD}{self.host.background}{A.RESET}\n")
+        for attr in dir(C):
+            if not attr.startswith("A_"):
+                continue
+            color = getattr(C, attr)
+            if attr == "A_LEVEL_COLORS":
+                # This constant contains multiple colors
+                self.disp("LEVEL COLORS: ", end=" ")
+                for idx, c in enumerate(color):
+                    last = idx == len(color) - 1
+                    end = "\n" if last else " "
+                    self.disp(
+                        c + f"LEVEL_{idx}" + A.RESET + (", " if not last else ""), end=end
+                    )
+            else:
+                text = attr[2:]
+                self.disp(A.color(color, text))
+        self.host.quit()
+
+
+class Debug(base.CommandBase):
+    subcommands = (bridge, Monitor, Theme)
+
+    def __init__(self, host):
+        super(Debug, self).__init__(
+            host, "debug", use_profile=False, help=_("debugging tools")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_encryption.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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 libervia.frontends.jp import base
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.frontends.jp import xmlui_manager
+
+__commands__ = ["Encryption"]
+
+
+class EncryptionAlgorithms(base.CommandBase):
+
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        super(EncryptionAlgorithms, self).__init__(
+            host, "algorithms",
+            use_output=C.OUTPUT_LIST_DICT,
+            extra_outputs=extra_outputs,
+            use_profile=False,
+            help=_("show available encryption algorithms"))
+
+    def add_parser_options(self):
+        pass
+
+    def default_output(self, plugins):
+        if not plugins:
+            self.disp(_("No encryption plugin registered!"))
+        else:
+            self.disp(_("Following encryption algorithms are available: {algos}").format(
+                algos=', '.join([p['name'] for p in plugins])))
+
+    async def start(self):
+        try:
+            plugins_ser = await self.host.bridge.encryption_plugins_get()
+            plugins = data_format.deserialise(plugins_ser, type_check=list)
+        except Exception as e:
+            self.disp(f"can't retrieve plugins: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(plugins)
+            self.host.quit()
+
+
+class EncryptionGet(base.CommandBase):
+
+    def __init__(self, host):
+        super(EncryptionGet, self).__init__(
+            host, "get",
+            use_output=C.OUTPUT_DICT,
+            help=_("get encryption session data"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the entity to check")
+        )
+
+    async def start(self):
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+        try:
+            serialised = await self.host.bridge.message_encryption_get(jid, self.profile)
+        except Exception as e:
+            self.disp(f"can't get session: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        session_data = data_format.deserialise(serialised)
+        if session_data is None:
+            self.disp(
+                "No encryption session found, the messages are sent in plain text.")
+            self.host.quit(C.EXIT_NOT_FOUND)
+        await self.output(session_data)
+        self.host.quit()
+
+
+class EncryptionStart(base.CommandBase):
+
+    def __init__(self, host):
+        super(EncryptionStart, self).__init__(
+            host, "start",
+            help=_("start encrypted session with an entity"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--encrypt-noreplace",
+            action="store_true",
+            help=_("don't replace encryption algorithm if an other one is already used"))
+        algorithm = self.parser.add_mutually_exclusive_group()
+        algorithm.add_argument(
+            "-n", "--name", help=_("algorithm name (DEFAULT: choose automatically)"))
+        algorithm.add_argument(
+            "-N", "--namespace",
+            help=_("algorithm namespace (DEFAULT: choose automatically)"))
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the entity to stop encrypted session with")
+        )
+
+    async def start(self):
+        if self.args.name is not None:
+            try:
+                namespace = await self.host.bridge.encryption_namespace_get(self.args.name)
+            except Exception as e:
+                self.disp(f"can't get encryption namespace: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        elif self.args.namespace is not None:
+            namespace = self.args.namespace
+        else:
+            namespace = ""
+
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+
+        try:
+            await self.host.bridge.message_encryption_start(
+                jid, namespace, not self.args.encrypt_noreplace,
+                self.profile)
+        except Exception as e:
+            self.disp(f"can't get encryption namespace: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+
+class EncryptionStop(base.CommandBase):
+
+    def __init__(self, host):
+        super(EncryptionStop, self).__init__(
+            host, "stop",
+            help=_("stop encrypted session with an entity"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the entity to stop encrypted session with")
+        )
+
+    async def start(self):
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+        try:
+            await self.host.bridge.message_encryption_stop(jid, self.profile)
+        except Exception as e:
+            self.disp(f"can't end encrypted session: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+
+class TrustUI(base.CommandBase):
+
+    def __init__(self, host):
+        super(TrustUI, self).__init__(
+            host, "ui",
+            help=_("get UI to manage trust"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the entity to stop encrypted session with")
+        )
+        algorithm = self.parser.add_mutually_exclusive_group()
+        algorithm.add_argument(
+            "-n", "--name", help=_("algorithm name (DEFAULT: current algorithm)"))
+        algorithm.add_argument(
+            "-N", "--namespace",
+            help=_("algorithm namespace (DEFAULT: current algorithm)"))
+
+    async def start(self):
+        if self.args.name is not None:
+            try:
+                namespace = await self.host.bridge.encryption_namespace_get(self.args.name)
+            except Exception as e:
+                self.disp(f"can't get encryption namespace: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        elif self.args.namespace is not None:
+            namespace = self.args.namespace
+        else:
+            namespace = ""
+
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+
+        try:
+            xmlui_raw = await self.host.bridge.encryption_trust_ui_get(
+                jid, namespace, self.profile)
+        except Exception as e:
+            self.disp(f"can't get encryption session trust UI: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        xmlui = xmlui_manager.create(self.host, xmlui_raw)
+        await xmlui.show()
+        if xmlui.type != C.XMLUI_DIALOG:
+            await xmlui.submit_form()
+        self.host.quit()
+
+class EncryptionTrust(base.CommandBase):
+    subcommands = (TrustUI,)
+
+    def __init__(self, host):
+        super(EncryptionTrust, self).__init__(
+            host, "trust", use_profile=False, help=_("trust manangement")
+        )
+
+
+class Encryption(base.CommandBase):
+    subcommands = (EncryptionAlgorithms, EncryptionGet, EncryptionStart, EncryptionStop,
+                   EncryptionTrust)
+
+    def __init__(self, host):
+        super(Encryption, self).__init__(
+            host, "encryption", use_profile=False, help=_("encryption sessions handling")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_event.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,755 @@
+#!/usr/bin/env python3
+
+
+# libervia-cli: Libervia CLI frontend
+# Copyright (C) 2009-2021 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/>.
+
+
+import argparse
+import sys
+
+from sqlalchemy import desc
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.frontends.jp import common
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.jp.constants import Const as C
+
+from . import base
+
+__commands__ = ["Event"]
+
+OUTPUT_OPT_TABLE = "table"
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_LIST_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS, C.CACHE},
+            use_verbose=True,
+            extra_outputs={
+                "default": self.default_output,
+            },
+            help=_("get event(s) data"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            events_data_s = await self.host.bridge.events_get(
+                self.args.service,
+                self.args.node,
+                self.args.items,
+                self.get_pubsub_extra(),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get events data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            events_data = data_format.deserialise(events_data_s, type_check=list)
+            await self.output(events_data)
+            self.host.quit()
+
+    def default_output(self, events):
+        nb_events = len(events)
+        for idx, event in enumerate(events):
+            names = event["name"]
+            name = names.get("") or next(iter(names.values()))
+            start = event["start"]
+            start_human = date_utils.date_fmt(
+                start, "medium", tz_info=date_utils.TZ_LOCAL
+            )
+            end = event["end"]
+            self.disp(A.color(
+                A.BOLD, start_human, A.RESET, " ",
+                f"({date_utils.delta2human(start, end)}) ",
+                C.A_HEADER, name
+            ))
+            if self.verbosity > 0:
+                descriptions = event.get("descriptions", [])
+                if descriptions:
+                    self.disp(descriptions[0]["description"])
+            if idx < (nb_events-1):
+                self.disp("")
+
+
+class CategoryAction(argparse.Action):
+
+    def __init__(self, option_strings, dest, nargs=None, metavar=None, **kwargs):
+        if nargs is not None or metavar is not None:
+            raise ValueError("nargs and metavar must not be used")
+        if metavar is not None:
+            metavar="TERM WIKIDATA_ID LANG"
+        if "--help" in sys.argv:
+            # FIXME: dirty workaround to have correct --help message
+            #   argparse doesn't normally allow variable number of arguments beside "+"
+            #   and "*", this workaround show METAVAR as 3 arguments were expected, while
+            #   we can actuall use 1, 2 or 3.
+            nargs = 3
+            metavar = ("TERM", "[WIKIDATA_ID]", "[LANG]")
+        else:
+            nargs = "+"
+
+        super().__init__(option_strings, dest, metavar=metavar, nargs=nargs, **kwargs)
+
+    def __call__(self, parser, namespace, values, option_string=None):
+        categories = getattr(namespace, self.dest)
+        if categories is None:
+            categories = []
+            setattr(namespace, self.dest, categories)
+
+        if not values:
+            parser.error("category values must be set")
+
+        category = {
+            "term": values[0]
+        }
+
+        if len(values) == 1:
+            pass
+        elif len(values) == 2:
+            value = values[1]
+            if value.startswith("Q"):
+                category["wikidata_id"] = value
+            else:
+                category["language"] = value
+        elif len(values) == 3:
+            __, wd, lang = values
+            category["wikidata_id"] = wd
+            category["language"] = lang
+        else:
+            parser.error("Category can't have more than 3 arguments")
+
+        categories.append(category)
+
+
+class EventBase:
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-S", "--start", type=base.date_decoder, metavar="TIME_PATTERN",
+            help=_("the start time of the event"))
+        end_group = self.parser.add_mutually_exclusive_group()
+        end_group.add_argument(
+            "-E", "--end", type=base.date_decoder, metavar="TIME_PATTERN",
+            help=_("the time of the end of the event"))
+        end_group.add_argument(
+            "-D", "--duration", help=_("duration of the event"))
+        self.parser.add_argument(
+            "-H", "--head-picture", help="URL to a picture to use as head-picture"
+        )
+        self.parser.add_argument(
+            "-d", "--description", help="plain text description the event"
+        )
+        self.parser.add_argument(
+            "-C", "--category", action=CategoryAction, dest="categories",
+            help="Category of the event"
+        )
+        self.parser.add_argument(
+            "-l", "--location", action="append", nargs="+", metavar="[KEY] VALUE",
+            help="Location metadata"
+        )
+        rsvp_group = self.parser.add_mutually_exclusive_group()
+        rsvp_group.add_argument(
+            "--rsvp", action="store_true", help=_("RSVP is requested"))
+        rsvp_group.add_argument(
+            "--rsvp_json", metavar="JSON", help=_("JSON description of RSVP form"))
+        for node_type in ("invitees", "comments", "blog", "schedule"):
+            self.parser.add_argument(
+                f"--{node_type}",
+                nargs=2,
+                metavar=("JID", "NODE"),
+                help=_("link {node_type} pubsub node").format(node_type=node_type)
+            )
+        self.parser.add_argument(
+            "-a", "--attachment", action="append", dest="attachments",
+            help=_("attach a file")
+        )
+        self.parser.add_argument("--website", help=_("website of the event"))
+        self.parser.add_argument(
+            "--status", choices=["confirmed", "tentative", "cancelled"],
+            help=_("status of the event")
+        )
+        self.parser.add_argument(
+            "-T", "--language", metavar="LANG", action="append", dest="languages",
+            help=_("main languages spoken at the event")
+        )
+        self.parser.add_argument(
+            "--wheelchair", choices=["full", "partial", "no"],
+            help=_("is the location accessible by wheelchair")
+        )
+        self.parser.add_argument(
+            "--external",
+            nargs=3,
+            metavar=("JID", "NODE", "ITEM"),
+            help=_("link to an external event")
+        )
+
+    def get_event_data(self):
+        if self.args.duration is not None:
+            if self.args.start is None:
+                self.parser.error("--start must be send if --duration is used")
+            # if duration is used, we simply add it to start time to get end time
+            self.args.end = base.date_decoder(f"{self.args.start} + {self.args.duration}")
+
+        event = {}
+        if self.args.name is not None:
+            event["name"] = {"": self.args.name}
+
+        if self.args.start is not None:
+            event["start"] = self.args.start
+
+        if self.args.end is not None:
+            event["end"] = self.args.end
+
+        if self.args.head_picture:
+            event["head-picture"] = {
+                "sources": [{
+                    "url": self.args.head_picture
+                }]
+            }
+        if self.args.description:
+            event["descriptions"] = [
+                {
+                    "type": "text",
+                    "description": self.args.description
+                }
+            ]
+        if self.args.categories:
+            event["categories"] = self.args.categories
+        if self.args.location is not None:
+            location = {}
+            for location_data in self.args.location:
+                if len(location_data) == 1:
+                    location["description"] = location_data[0]
+                else:
+                    key, *values = location_data
+                    location[key] = " ".join(values)
+            event["locations"] = [location]
+
+        if self.args.rsvp:
+            event["rsvp"] = [{}]
+        elif self.args.rsvp_json:
+            if isinstance(self.args.rsvp_elt, dict):
+                event["rsvp"] = [self.args.rsvp_json]
+            else:
+                event["rsvp"] = self.args.rsvp_json
+
+        for node_type in ("invitees", "comments", "blog", "schedule"):
+            value = getattr(self.args, node_type)
+            if value:
+                service, node = value
+                event[node_type] = {"service": service, "node": node}
+
+        if self.args.attachments:
+            attachments = event["attachments"] = []
+            for attachment in self.args.attachments:
+                attachments.append({
+                    "sources": [{"url": attachment}]
+                })
+
+        extra = {}
+
+        for arg in ("website", "status", "languages"):
+            value = getattr(self.args, arg)
+            if value is not None:
+                extra[arg] = value
+        if self.args.wheelchair is not None:
+            extra["accessibility"] = {"wheelchair": self.args.wheelchair}
+
+        if extra:
+            event["extra"] = extra
+
+        if self.args.external:
+            ext_jid, ext_node, ext_item = self.args.external
+            event["external"] = {
+                "jid": ext_jid,
+                "node": ext_node,
+                "item": ext_item
+            }
+        return event
+
+
+class Create(EventBase, base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "create",
+            use_pubsub=True,
+            help=_("create or replace event"),
+        )
+
+    def add_parser_options(self):
+        super().add_parser_options()
+        self.parser.add_argument(
+            "-i",
+            "--id",
+            default="",
+            help=_("ID of the PubSub Item"),
+        )
+        # name is mandatory here
+        self.parser.add_argument("name", help=_("name of the event"))
+
+    async def start(self):
+        if self.args.start is None:
+            self.parser.error("--start must be set")
+        event_data = self.get_event_data()
+        # we check self.args.end after get_event_data because it may be set there id
+        # --duration is used
+        if self.args.end is None:
+            self.parser.error("--end or --duration must be set")
+        try:
+            await self.host.bridge.event_create(
+                data_format.serialise(event_data),
+                self.args.id,
+                self.args.node,
+                self.args.service,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create event: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("Event created successfuly)"))
+            self.host.quit()
+
+
+class Modify(EventBase, base.CommandBase):
+    def __init__(self, host):
+        super(Modify, self).__init__(
+            host,
+            "modify",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("modify an existing event"),
+        )
+        EventBase.__init__(self)
+
+    def add_parser_options(self):
+        super().add_parser_options()
+        # name is optional here
+        self.parser.add_argument("-N", "--name", help=_("name of the event"))
+
+    async def start(self):
+        event_data = self.get_event_data()
+        try:
+            await self.host.bridge.event_modify(
+                data_format.serialise(event_data),
+                self.args.item,
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't update event data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class InviteeGet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            use_verbose=True,
+            help=_("get event attendance"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j", "--jid", action="append", dest="jids", default=[],
+            help=_("only retrieve RSVP from those JIDs")
+        )
+
+    async def start(self):
+        try:
+            event_data_s = await self.host.bridge.event_invitee_get(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.jids,
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get event data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            event_data = data_format.deserialise(event_data_s)
+            await self.output(event_data)
+            self.host.quit()
+
+
+class InviteeSet(base.CommandBase):
+    def __init__(self, host):
+        super(InviteeSet, self).__init__(
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM},
+            help=_("set event attendance"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            metavar=("KEY", "VALUE"),
+            help=_("configuration field to set"),
+        )
+
+    async def start(self):
+        # TODO: handle RSVP with XMLUI in a similar way as for `ad-hoc run`
+        fields = dict(self.args.fields) if self.args.fields else {}
+        try:
+            self.host.bridge.event_invitee_set(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                data_format.serialise(fields),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set event data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class InviteesList(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        base.CommandBase.__init__(
+            self,
+            host,
+            "list",
+            use_output=C.OUTPUT_DICT_DICT,
+            extra_outputs=extra_outputs,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("get event attendance"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-m",
+            "--missing",
+            action="store_true",
+            help=_("show missing people (invited but no R.S.V.P. so far)"),
+        )
+        self.parser.add_argument(
+            "-R",
+            "--no-rsvp",
+            action="store_true",
+            help=_("don't show people which gave R.S.V.P."),
+        )
+
+    def _attend_filter(self, attend, row):
+        if attend == "yes":
+            attend_color = C.A_SUCCESS
+        elif attend == "no":
+            attend_color = C.A_FAILURE
+        else:
+            attend_color = A.FG_WHITE
+        return A.color(attend_color, attend)
+
+    def _guests_filter(self, guests):
+        return "(" + str(guests) + ")" if guests else ""
+
+    def default_output(self, event_data):
+        data = []
+        attendees_yes = 0
+        attendees_maybe = 0
+        attendees_no = 0
+        attendees_missing = 0
+        guests = 0
+        guests_maybe = 0
+        for jid_, jid_data in event_data.items():
+            jid_data["jid"] = jid_
+            try:
+                guests_int = int(jid_data["guests"])
+            except (ValueError, KeyError):
+                pass
+            attend = jid_data.get("attend", "")
+            if attend == "yes":
+                attendees_yes += 1
+                guests += guests_int
+            elif attend == "maybe":
+                attendees_maybe += 1
+                guests_maybe += guests_int
+            elif attend == "no":
+                attendees_no += 1
+                jid_data["guests"] = ""
+            else:
+                attendees_missing += 1
+                jid_data["guests"] = ""
+            data.append(jid_data)
+
+        show_table = OUTPUT_OPT_TABLE in self.args.output_opts
+
+        table = common.Table.from_list_dict(
+            self.host,
+            data,
+            ("nick",) + (("jid",) if self.host.verbosity else ()) + ("attend", "guests"),
+            headers=None,
+            filters={
+                "nick": A.color(C.A_HEADER, "{}" if show_table else "{} "),
+                "jid": "{}" if show_table else "{} ",
+                "attend": self._attend_filter,
+                "guests": "{}" if show_table else self._guests_filter,
+            },
+            defaults={"nick": "", "attend": "", "guests": 1},
+        )
+        if show_table:
+            table.display()
+        else:
+            table.display_blank(show_header=False, col_sep="")
+
+        if not self.args.no_rsvp:
+            self.disp("")
+            self.disp(
+                A.color(
+                    C.A_SUBHEADER,
+                    _("Attendees: "),
+                    A.RESET,
+                    str(len(data)),
+                    _(" ("),
+                    C.A_SUCCESS,
+                    _("yes: "),
+                    str(attendees_yes),
+                    A.FG_WHITE,
+                    _(", maybe: "),
+                    str(attendees_maybe),
+                    ", ",
+                    C.A_FAILURE,
+                    _("no: "),
+                    str(attendees_no),
+                    A.RESET,
+                    ")",
+                )
+            )
+            self.disp(
+                A.color(C.A_SUBHEADER, _("confirmed guests: "), A.RESET, str(guests))
+            )
+            self.disp(
+                A.color(
+                    C.A_SUBHEADER,
+                    _("unconfirmed guests: "),
+                    A.RESET,
+                    str(guests_maybe),
+                )
+            )
+            self.disp(
+                A.color(C.A_SUBHEADER, _("total: "), A.RESET, str(guests + guests_maybe))
+            )
+        if attendees_missing:
+            self.disp("")
+            self.disp(
+                A.color(
+                    C.A_SUBHEADER,
+                    _("missing people (no reply): "),
+                    A.RESET,
+                    str(attendees_missing),
+                )
+            )
+
+    async def start(self):
+        if self.args.no_rsvp and not self.args.missing:
+            self.parser.error(_("you need to use --missing if you use --no-rsvp"))
+        if not self.args.missing:
+            prefilled = {}
+        else:
+            # we get prefilled data with all people
+            try:
+                affiliations = await self.host.bridge.ps_node_affiliations_get(
+                    self.args.service,
+                    self.args.node,
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(f"can't get node affiliations: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                # we fill all affiliations with empty data, answered one will be filled
+                # below. We only consider people with "publisher" affiliation as invited,
+                # creators are not, and members can just observe
+                prefilled = {
+                    jid_: {}
+                    for jid_, affiliation in affiliations.items()
+                    if affiliation in ("publisher",)
+                }
+
+        try:
+            event_data = await self.host.bridge.event_invitees_list(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get event data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            # we fill nicknames and keep only requested people
+
+        if self.args.no_rsvp:
+            for jid_ in event_data:
+                # if there is a jid in event_data it must be there in prefilled too
+                # otherwie somebody is not on the invitees list
+                try:
+                    del prefilled[jid_]
+                except KeyError:
+                    self.disp(
+                        A.color(
+                            C.A_WARNING,
+                            f"We got a RSVP from somebody who was not in invitees "
+                            f"list: {jid_}",
+                        ),
+                        error=True,
+                    )
+        else:
+            # we replace empty dicts for existing people with R.S.V.P. data
+            prefilled.update(event_data)
+
+            # we get nicknames for everybody, make it easier for organisers
+        for jid_, data in prefilled.items():
+            id_data = await self.host.bridge.identity_get(jid_, [], True, self.profile)
+            id_data = data_format.deserialise(id_data)
+            data["nick"] = id_data["nicknames"][0]
+
+        await self.output(prefilled)
+        self.host.quit()
+
+
+class InviteeInvite(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "invite",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("invite someone to the event through email"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-e",
+            "--email",
+            action="append",
+            default=[],
+            help="email(s) to send the invitation to",
+        )
+        self.parser.add_argument(
+            "-N",
+            "--name",
+            default="",
+            help="name of the invitee",
+        )
+        self.parser.add_argument(
+            "-H",
+            "--host-name",
+            default="",
+            help="name of the host",
+        )
+        self.parser.add_argument(
+            "-l",
+            "--lang",
+            default="",
+            help="main language spoken by the invitee",
+        )
+        self.parser.add_argument(
+            "-U",
+            "--url-template",
+            default="",
+            help="template to construct the URL",
+        )
+        self.parser.add_argument(
+            "-S",
+            "--subject",
+            default="",
+            help="subject of the invitation email (default: generic subject)",
+        )
+        self.parser.add_argument(
+            "-b",
+            "--body",
+            default="",
+            help="body of the invitation email (default: generic body)",
+        )
+
+    async def start(self):
+        email = self.args.email[0] if self.args.email else None
+        emails_extra = self.args.email[1:]
+
+        try:
+            await self.host.bridge.event_invite_by_email(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                email,
+                emails_extra,
+                self.args.name,
+                self.args.host_name,
+                self.args.lang,
+                self.args.url_template,
+                self.args.subject,
+                self.args.body,
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create invitation: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Invitee(base.CommandBase):
+    subcommands = (InviteeGet, InviteeSet, InviteesList, InviteeInvite)
+
+    def __init__(self, host):
+        super(Invitee, self).__init__(
+            host, "invitee", use_profile=False, help=_("manage invities")
+        )
+
+
+class Event(base.CommandBase):
+    subcommands = (Get, Create, Modify, Invitee)
+
+    def __init__(self, host):
+        super(Event, self).__init__(
+            host, "event", use_profile=False, help=_("event management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_file.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1108 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from . import xmlui_manager
+import sys
+import os
+import os.path
+import tarfile
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.jp import common
+from libervia.frontends.tools import jid
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import utils
+from urllib.parse import urlparse
+from pathlib import Path
+import tempfile
+import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
+import json
+
+__commands__ = ["File"]
+DEFAULT_DEST = "downloaded_file"
+
+
+class Send(base.CommandBase):
+    def __init__(self, host):
+        super(Send, self).__init__(
+            host,
+            "send",
+            use_progress=True,
+            use_verbose=True,
+            help=_("send a file to a contact"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "files", type=str, nargs="+", metavar="file", help=_("a list of file")
+        )
+        self.parser.add_argument("jid", help=_("the destination jid"))
+        self.parser.add_argument(
+            "-b", "--bz2", action="store_true", help=_("make a bzip2 tarball")
+        )
+        self.parser.add_argument(
+            "-d",
+            "--path",
+            help=("path to the directory where the file must be stored"),
+        )
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            help=("namespace of the file"),
+        )
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help=("name to use (DEFAULT: use source file name)"),
+        )
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("end-to-end encrypt the file transfer")
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File copy started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File sent successfully"), 2)
+
+    async def on_progress_error(self, error_msg):
+        if error_msg == C.PROGRESS_ERROR_DECLINED:
+            self.disp(_("The file has been refused by your contact"))
+        else:
+            self.disp(_("Error while sending file: {}").format(error_msg), error=True)
+
+    async def got_id(self, data, file_):
+        """Called when a progress id has been received
+
+        @param pid(unicode): progress id
+        @param file_(str): file path
+        """
+        # FIXME: this show progress only for last progress_id
+        self.disp(_("File request sent to {jid}".format(jid=self.args.jid)), 1)
+        try:
+            await self.set_progress_id(data["progress"])
+        except KeyError:
+            # TODO: if 'xmlui' key is present, manage xmlui message display
+            self.disp(_("Can't send file to {jid}".format(jid=self.args.jid)), error=True)
+            self.host.quit(2)
+
+    async def start(self):
+        for file_ in self.args.files:
+            if not os.path.exists(file_):
+                self.disp(
+                    _("file {file_} doesn't exist!").format(file_=repr(file_)), error=True
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+            if not self.args.bz2 and os.path.isdir(file_):
+                self.disp(
+                    _(
+                        "{file_} is a dir! Please send files inside or use compression"
+                    ).format(file_=repr(file_))
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+
+        extra = {}
+        if self.args.path:
+            extra["path"] = self.args.path
+        if self.args.namespace:
+            extra["namespace"] = self.args.namespace
+        if self.args.encrypt:
+            extra["encrypted"] = True
+
+        if self.args.bz2:
+            with tempfile.NamedTemporaryFile("wb", delete=False) as buf:
+                self.host.add_on_quit_callback(os.unlink, buf.name)
+                self.disp(_("bz2 is an experimental option, use with caution"))
+                # FIXME: check free space
+                self.disp(_("Starting compression, please wait..."))
+                sys.stdout.flush()
+                bz2 = tarfile.open(mode="w:bz2", fileobj=buf)
+                archive_name = "{}.tar.bz2".format(
+                    os.path.basename(self.args.files[0]) or "compressed_files"
+                )
+                for file_ in self.args.files:
+                    self.disp(_("Adding {}").format(file_), 1)
+                    bz2.add(file_)
+                bz2.close()
+                self.disp(_("Done !"), 1)
+
+                try:
+                    send_data = await self.host.bridge.file_send(
+                        self.args.jid,
+                        buf.name,
+                        self.args.name or archive_name,
+                        "",
+                        data_format.serialise(extra),
+                        self.profile,
+                    )
+                except Exception as e:
+                    self.disp(f"can't send file: {e}", error=True)
+                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+                else:
+                    await self.got_id(send_data, file_)
+        else:
+            for file_ in self.args.files:
+                path = os.path.abspath(file_)
+                try:
+                    send_data = await self.host.bridge.file_send(
+                        self.args.jid,
+                        path,
+                        self.args.name,
+                        "",
+                        data_format.serialise(extra),
+                        self.profile,
+                    )
+                except Exception as e:
+                    self.disp(f"can't send file {file_!r}: {e}", error=True)
+                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+                else:
+                    await self.got_id(send_data, file_)
+
+
+class Request(base.CommandBase):
+    def __init__(self, host):
+        super(Request, self).__init__(
+            host,
+            "request",
+            use_progress=True,
+            use_verbose=True,
+            help=_("request a file from a contact"),
+        )
+
+    @property
+    def filename(self):
+        return self.args.name or self.args.hash or "output"
+
+    def add_parser_options(self):
+        self.parser.add_argument("jid", help=_("the destination jid"))
+        self.parser.add_argument(
+            "-D",
+            "--dest",
+            help=_(
+                "destination path where the file will be saved (default: "
+                "[current_dir]/[name|hash])"
+            ),
+        )
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help=_("name of the file"),
+        )
+        self.parser.add_argument(
+            "-H",
+            "--hash",
+            default="",
+            help=_("hash of the file"),
+        )
+        self.parser.add_argument(
+            "-a",
+            "--hash-algo",
+            default="sha-256",
+            help=_("hash algorithm use for --hash (default: sha-256)"),
+        )
+        self.parser.add_argument(
+            "-d",
+            "--path",
+            help=("path to the directory containing the file"),
+        )
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            help=("namespace of the file"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("overwrite existing file without confirmation"),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File copy started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File received successfully"), 2)
+
+    async def on_progress_error(self, error_msg):
+        if error_msg == C.PROGRESS_ERROR_DECLINED:
+            self.disp(_("The file request has been refused"))
+        else:
+            self.disp(_("Error while requesting file: {}").format(error_msg), error=True)
+
+    async def start(self):
+        if not self.args.name and not self.args.hash:
+            self.parser.error(_("at least one of --name or --hash must be provided"))
+        if self.args.dest:
+            path = os.path.abspath(os.path.expanduser(self.args.dest))
+            if os.path.isdir(path):
+                path = os.path.join(path, self.filename)
+        else:
+            path = os.path.abspath(self.filename)
+
+        if os.path.exists(path) and not self.args.force:
+            message = _("File {path} already exists! Do you want to overwrite?").format(
+                path=path
+            )
+            await self.host.confirm_or_quit(message, _("file request cancelled"))
+
+        self.full_dest_jid = await self.host.get_full_jid(self.args.jid)
+        extra = {}
+        if self.args.path:
+            extra["path"] = self.args.path
+        if self.args.namespace:
+            extra["namespace"] = self.args.namespace
+        try:
+            progress_id = await self.host.bridge.file_jingle_request(
+                self.full_dest_jid,
+                path,
+                self.args.name,
+                self.args.hash,
+                self.args.hash_algo if self.args.hash else "",
+                extra,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't request file: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.set_progress_id(progress_id)
+
+
+class Receive(base.CommandAnswering):
+    def __init__(self, host):
+        super(Receive, self).__init__(
+            host,
+            "receive",
+            use_progress=True,
+            use_verbose=True,
+            help=_("wait for a file to be sent by a contact"),
+        )
+        self._overwrite_refused = False  # True when one overwrite as already been refused
+        self.action_callbacks = {
+            C.META_TYPE_FILE: self.on_file_action,
+            C.META_TYPE_OVERWRITE: self.on_overwrite_action,
+            C.META_TYPE_NOT_IN_ROSTER_LEAK: self.on_not_in_roster_action,
+        }
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jids",
+            nargs="*",
+            help=_("jids accepted (accept everything if none is specified)"),
+        )
+        self.parser.add_argument(
+            "-m",
+            "--multiple",
+            action="store_true",
+            help=_("accept multiple files (you'll have to stop manually)"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_(
+                "force overwritting of existing files (/!\\ name is choosed by sender)"
+            ),
+        )
+        self.parser.add_argument(
+            "--path",
+            default=".",
+            metavar="DIR",
+            help=_("destination path (default: working directory)"),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File copy started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File received successfully"), 2)
+        if metadata.get("hash_verified", False):
+            try:
+                self.disp(
+                    _("hash checked: {metadata['hash_algo']}:{metadata['hash']}"), 1
+                )
+            except KeyError:
+                self.disp(_("hash is checked but hash value is missing", 1), error=True)
+        else:
+            self.disp(_("hash can't be verified"), 1)
+
+    async def on_progress_error(self, e):
+        self.disp(_("Error while receiving file: {e}").format(e=e), error=True)
+
+    def get_xmlui_id(self, action_data):
+        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
+        #        should be available in the futur
+        # TODO: XMLUI module
+        try:
+            xml_ui = action_data["xmlui"]
+        except KeyError:
+            self.disp(_("Action has no XMLUI"), 1)
+        else:
+            ui = ET.fromstring(xml_ui.encode("utf-8"))
+            xmlui_id = ui.get("submit")
+            if not xmlui_id:
+                self.disp(_("Invalid XMLUI received"), error=True)
+            return xmlui_id
+
+    async def on_file_action(self, action_data, action_id, security_limit, profile):
+        xmlui_id = self.get_xmlui_id(action_data)
+        if xmlui_id is None:
+            return self.host.quit_from_signal(1)
+        try:
+            from_jid = jid.JID(action_data["from_jid"])
+        except KeyError:
+            self.disp(_("Ignoring action without from_jid data"), 1)
+            return
+        try:
+            progress_id = action_data["progress_id"]
+        except KeyError:
+            self.disp(_("ignoring action without progress id"), 1)
+            return
+
+        if not self.bare_jids or from_jid.bare in self.bare_jids:
+            if self._overwrite_refused:
+                self.disp(_("File refused because overwrite is needed"), error=True)
+                await self.host.bridge.action_launch(
+                    xmlui_id, data_format.serialise({"cancelled": C.BOOL_TRUE}),
+                    profile_key=profile
+                )
+                return self.host.quit_from_signal(2)
+            await self.set_progress_id(progress_id)
+            xmlui_data = {"path": self.path}
+            await self.host.bridge.action_launch(
+                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
+            )
+
+    async def on_overwrite_action(self, action_data, action_id, security_limit, profile):
+        xmlui_id = self.get_xmlui_id(action_data)
+        if xmlui_id is None:
+            return self.host.quit_from_signal(1)
+        try:
+            progress_id = action_data["progress_id"]
+        except KeyError:
+            self.disp(_("ignoring action without progress id"), 1)
+            return
+        self.disp(_("Overwriting needed"), 1)
+
+        if progress_id == self.progress_id:
+            if self.args.force:
+                self.disp(_("Overwrite accepted"), 2)
+            else:
+                self.disp(_("Refused to overwrite"), 2)
+                self._overwrite_refused = True
+
+            xmlui_data = {"answer": C.bool_const(self.args.force)}
+            await self.host.bridge.action_launch(
+                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
+            )
+
+    async def on_not_in_roster_action(self, action_data, action_id, security_limit, profile):
+        xmlui_id = self.get_xmlui_id(action_data)
+        if xmlui_id is None:
+            return self.host.quit_from_signal(1)
+        try:
+            from_jid = jid.JID(action_data["from_jid"])
+        except ValueError:
+            self.disp(
+                _('invalid "from_jid" value received, ignoring: {value}').format(
+                    value=from_jid
+                ),
+                error=True,
+            )
+            return
+        except KeyError:
+            self.disp(_('ignoring action without "from_jid" value'), error=True)
+            return
+        self.disp(_("Confirmation needed for request from an entity not in roster"), 1)
+
+        if from_jid.bare in self.bare_jids:
+            # if the sender is expected, we can confirm the session
+            confirmed = True
+            self.disp(_("Sender confirmed because she or he is explicitly expected"), 1)
+        else:
+            xmlui = xmlui_manager.create(self.host, action_data["xmlui"])
+            confirmed = await self.host.confirm(xmlui.dlg.message)
+
+        xmlui_data = {"answer": C.bool_const(confirmed)}
+        await self.host.bridge.action_launch(
+            xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
+        )
+        if not confirmed and not self.args.multiple:
+            self.disp(_("Session refused for {from_jid}").format(from_jid=from_jid))
+            self.host.quit_from_signal(0)
+
+    async def start(self):
+        self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids]
+        self.path = os.path.abspath(self.args.path)
+        if not os.path.isdir(self.path):
+            self.disp(_("Given path is not a directory !", error=True))
+            self.host.quit(C.EXIT_BAD_ARG)
+        if self.args.multiple:
+            self.host.quit_on_progress_end = False
+        self.disp(_("waiting for incoming file request"), 2)
+        await self.start_answering()
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        super(Get, self).__init__(
+            host,
+            "get",
+            use_progress=True,
+            use_verbose=True,
+            help=_("download a file from URI"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-o",
+            "--dest-file",
+            type=str,
+            default="",
+            help=_("destination file (DEFAULT: filename from URL)"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("overwrite existing file without confirmation"),
+        )
+        self.parser.add_argument(
+            "attachment", type=str,
+            help=_("URI of the file to retrieve or JSON of the whole attachment")
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File download started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File downloaded successfully"), 2)
+
+    async def on_progress_error(self, error_msg):
+        self.disp(_("Error while downloading file: {}").format(error_msg), error=True)
+
+    async def got_id(self, data):
+        """Called when a progress id has been received"""
+        try:
+            await self.set_progress_id(data["progress"])
+        except KeyError:
+            if "xmlui" in data:
+                ui = xmlui_manager.create(self.host, data["xmlui"])
+                await ui.show()
+            else:
+                self.disp(_("Can't download file"), error=True)
+            self.host.quit(C.EXIT_ERROR)
+
+    async def start(self):
+        try:
+            attachment = json.loads(self.args.attachment)
+        except json.JSONDecodeError:
+            attachment = {"uri": self.args.attachment}
+        dest_file = self.args.dest_file
+        if not dest_file:
+            try:
+                dest_file = attachment["name"].replace("/", "-").strip()
+            except KeyError:
+                try:
+                    dest_file = Path(urlparse(attachment["uri"]).path).name.strip()
+                except KeyError:
+                    pass
+            if not dest_file:
+                dest_file = "downloaded_file"
+
+        dest_file = Path(dest_file).expanduser().resolve()
+        if dest_file.exists() and not self.args.force:
+            message = _("File {path} already exists! Do you want to overwrite?").format(
+                path=dest_file
+            )
+            await self.host.confirm_or_quit(message, _("file download cancelled"))
+
+        options = {}
+
+        try:
+            download_data_s = await self.host.bridge.file_download(
+                data_format.serialise(attachment),
+                str(dest_file),
+                data_format.serialise(options),
+                self.profile,
+            )
+            download_data = data_format.deserialise(download_data_s)
+        except Exception as e:
+            self.disp(f"error while trying to download a file: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.got_id(download_data)
+
+
+class Upload(base.CommandBase):
+    def __init__(self, host):
+        super(Upload, self).__init__(
+            host, "upload", use_progress=True, use_verbose=True, help=_("upload a file")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("encrypt file using AES-GCM"),
+        )
+        self.parser.add_argument("file", type=str, help=_("file to upload"))
+        self.parser.add_argument(
+            "jid",
+            nargs="?",
+            help=_("jid of upload component (nothing to autodetect)"),
+        )
+        self.parser.add_argument(
+            "--ignore-tls-errors",
+            action="store_true",
+            help=_(r"ignore invalide TLS certificate (/!\ Dangerous /!\)"),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("File upload started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("File uploaded successfully"), 2)
+        try:
+            url = metadata["url"]
+        except KeyError:
+            self.disp("download URL not found in metadata")
+        else:
+            self.disp(_("URL to retrieve the file:"), 1)
+            # XXX: url is displayed alone on a line to make parsing easier
+            self.disp(url)
+
+    async def on_progress_error(self, error_msg):
+        self.disp(_("Error while uploading file: {}").format(error_msg), error=True)
+
+    async def got_id(self, data, file_):
+        """Called when a progress id has been received
+
+        @param pid(unicode): progress id
+        @param file_(str): file path
+        """
+        try:
+            await self.set_progress_id(data["progress"])
+        except KeyError:
+            if "xmlui" in data:
+                ui = xmlui_manager.create(self.host, data["xmlui"])
+                await ui.show()
+            else:
+                self.disp(_("Can't upload file"), error=True)
+            self.host.quit(C.EXIT_ERROR)
+
+    async def start(self):
+        file_ = self.args.file
+        if not os.path.exists(file_):
+            self.disp(
+                _("file {file_} doesn't exist !").format(file_=repr(file_)), error=True
+            )
+            self.host.quit(C.EXIT_BAD_ARG)
+        if os.path.isdir(file_):
+            self.disp(_("{file_} is a dir! Can't upload a dir").format(file_=repr(file_)))
+            self.host.quit(C.EXIT_BAD_ARG)
+
+        if self.args.jid is None:
+            self.full_dest_jid = ""
+        else:
+            self.full_dest_jid = await self.host.get_full_jid(self.args.jid)
+
+        options = {}
+        if self.args.ignore_tls_errors:
+            options["ignore_tls_errors"] = True
+        if self.args.encrypt:
+            options["encryption"] = C.ENC_AES_GCM
+
+        path = os.path.abspath(file_)
+        try:
+            upload_data = await self.host.bridge.file_upload(
+                path,
+                "",
+                self.full_dest_jid,
+                data_format.serialise(options),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"error while trying to upload a file: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.got_id(upload_data, file_)
+
+
+class ShareAffiliationsSet(base.CommandBase):
+    def __init__(self, host):
+        super(ShareAffiliationsSet, self).__init__(
+            host,
+            "set",
+            use_output=C.OUTPUT_DICT,
+            help=_("set affiliations for a shared file/directory"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            default="",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "-a",
+            "--affiliation",
+            dest="affiliations",
+            metavar=("JID", "AFFILIATION"),
+            required=True,
+            action="append",
+            nargs=2,
+            help=_("entity/affiliation couple(s)"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of file sharing entity"),
+        )
+
+    async def start(self):
+        affiliations = dict(self.args.affiliations)
+        try:
+            affiliations = await self.host.bridge.fis_affiliations_set(
+                self.args.jid,
+                self.args.namespace,
+                self.args.path,
+                affiliations,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class ShareAffiliationsGet(base.CommandBase):
+    def __init__(self, host):
+        super(ShareAffiliationsGet, self).__init__(
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            help=_("retrieve affiliations of a shared file/directory"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            default="",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of sharing entity"),
+        )
+
+    async def start(self):
+        try:
+            affiliations = await self.host.bridge.fis_affiliations_get(
+                self.args.jid,
+                self.args.namespace,
+                self.args.path,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(affiliations)
+            self.host.quit()
+
+
+class ShareAffiliations(base.CommandBase):
+    subcommands = (ShareAffiliationsGet, ShareAffiliationsSet)
+
+    def __init__(self, host):
+        super(ShareAffiliations, self).__init__(
+            host, "affiliations", use_profile=False, help=_("affiliations management")
+        )
+
+
+class ShareConfigurationSet(base.CommandBase):
+    def __init__(self, host):
+        super(ShareConfigurationSet, self).__init__(
+            host,
+            "set",
+            use_output=C.OUTPUT_DICT,
+            help=_("set configuration for a shared file/directory"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            default="",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            required=True,
+            metavar=("KEY", "VALUE"),
+            help=_("configuration field to set (required)"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of file sharing entity"),
+        )
+
+    async def start(self):
+        configuration = dict(self.args.fields)
+        try:
+            configuration = await self.host.bridge.fis_configuration_set(
+                self.args.jid,
+                self.args.namespace,
+                self.args.path,
+                configuration,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set configuration: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class ShareConfigurationGet(base.CommandBase):
+    def __init__(self, host):
+        super(ShareConfigurationGet, self).__init__(
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            help=_("retrieve configuration of a shared file/directory"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            default="",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of sharing entity"),
+        )
+
+    async def start(self):
+        try:
+            configuration = await self.host.bridge.fis_configuration_get(
+                self.args.jid,
+                self.args.namespace,
+                self.args.path,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get configuration: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(configuration)
+            self.host.quit()
+
+
+class ShareConfiguration(base.CommandBase):
+    subcommands = (ShareConfigurationGet, ShareConfigurationSet)
+
+    def __init__(self, host):
+        super(ShareConfiguration, self).__init__(
+            host,
+            "configuration",
+            use_profile=False,
+            help=_("file sharing node configuration"),
+        )
+
+
+class ShareList(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        super(ShareList, self).__init__(
+            host,
+            "list",
+            use_output=C.OUTPUT_LIST_DICT,
+            extra_outputs=extra_outputs,
+            help=_("retrieve files shared by an entity"),
+            use_verbose=True,
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-d",
+            "--path",
+            default="",
+            help=_("path to the directory containing the files"),
+        )
+        self.parser.add_argument(
+            "jid",
+            nargs="?",
+            default="",
+            help=_("jid of sharing entity (nothing to check our own jid)"),
+        )
+
+    def _name_filter(self, name, row):
+        if row.type == C.FILE_TYPE_DIRECTORY:
+            return A.color(C.A_DIRECTORY, name)
+        elif row.type == C.FILE_TYPE_FILE:
+            return A.color(C.A_FILE, name)
+        else:
+            self.disp(_("unknown file type: {type}").format(type=row.type), error=True)
+            return name
+
+    def _size_filter(self, size, row):
+        if not size:
+            return ""
+        return A.color(A.BOLD, utils.get_human_size(size))
+
+    def default_output(self, files_data):
+        """display files a way similar to ls"""
+        files_data.sort(key=lambda d: d["name"].lower())
+        show_header = False
+        if self.verbosity == 0:
+            keys = headers = ("name", "type")
+        elif self.verbosity == 1:
+            keys = headers = ("name", "type", "size")
+        elif self.verbosity > 1:
+            show_header = True
+            keys = ("name", "type", "size", "file_hash")
+            headers = ("name", "type", "size", "hash")
+        table = common.Table.from_list_dict(
+            self.host,
+            files_data,
+            keys=keys,
+            headers=headers,
+            filters={"name": self._name_filter, "size": self._size_filter},
+            defaults={"size": "", "file_hash": ""},
+        )
+        table.display_blank(show_header=show_header, hide_cols=["type"])
+
+    async def start(self):
+        try:
+            files_data = await self.host.bridge.fis_list(
+                self.args.jid,
+                self.args.path,
+                {},
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't retrieve shared files: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        await self.output(files_data)
+        self.host.quit()
+
+
+class SharePath(base.CommandBase):
+    def __init__(self, host):
+        super(SharePath, self).__init__(
+            host, "path", help=_("share a file or directory"), use_verbose=True
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help=_("virtual name to use (default: use directory/file name)"),
+        )
+        perm_group = self.parser.add_mutually_exclusive_group()
+        perm_group.add_argument(
+            "-j",
+            "--jid",
+            metavar="JID",
+            action="append",
+            dest="jids",
+            default=[],
+            help=_("jid of contacts allowed to retrieve the files"),
+        )
+        perm_group.add_argument(
+            "--public",
+            action="store_true",
+            help=_(
+                r"share publicly the file(s) (/!\ *everybody* will be able to access "
+                r"them)"
+            ),
+        )
+        self.parser.add_argument(
+            "path",
+            help=_("path to a file or directory to share"),
+        )
+
+    async def start(self):
+        self.path = os.path.abspath(self.args.path)
+        if self.args.public:
+            access = {"read": {"type": "public"}}
+        else:
+            jids = self.args.jids
+            if jids:
+                access = {"read": {"type": "whitelist", "jids": jids}}
+            else:
+                access = {}
+        try:
+            name = await self.host.bridge.fis_share_path(
+                self.args.name,
+                self.path,
+                json.dumps(access, ensure_ascii=False),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't share path: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                _('{path} shared under the name "{name}"').format(
+                    path=self.path, name=name
+                )
+            )
+            self.host.quit()
+
+
+class ShareInvite(base.CommandBase):
+    def __init__(self, host):
+        super(ShareInvite, self).__init__(
+            host, "invite", help=_("send invitation for a shared repository")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help=_("name of the repository"),
+        )
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            default="",
+            help=_("namespace of the repository"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--path",
+            help=_("path to the repository"),
+        )
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            choices=["files", "photos"],
+            default="files",
+            help=_("type of the repository"),
+        )
+        self.parser.add_argument(
+            "-T",
+            "--thumbnail",
+            help=_("https URL of a image to use as thumbnail"),
+        )
+        self.parser.add_argument(
+            "service",
+            help=_("jid of the file sharing service hosting the repository"),
+        )
+        self.parser.add_argument(
+            "jid",
+            help=_("jid of the person to invite"),
+        )
+
+    async def start(self):
+        self.path = os.path.normpath(self.args.path) if self.args.path else ""
+        extra = {}
+        if self.args.thumbnail is not None:
+            if not self.args.thumbnail.startswith("http"):
+                self.parser.error(_("only http(s) links are allowed with --thumbnail"))
+            else:
+                extra["thumb_url"] = self.args.thumbnail
+        try:
+            await self.host.bridge.fis_invite(
+                self.args.jid,
+                self.args.service,
+                self.args.type,
+                self.args.namespace,
+                self.path,
+                self.args.name,
+                data_format.serialise(extra),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't send invitation: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("invitation sent to {jid}").format(jid=self.args.jid))
+            self.host.quit()
+
+
+class Share(base.CommandBase):
+    subcommands = (
+        ShareList,
+        SharePath,
+        ShareInvite,
+        ShareAffiliations,
+        ShareConfiguration,
+    )
+
+    def __init__(self, host):
+        super(Share, self).__init__(
+            host, "share", use_profile=False, help=_("files sharing management")
+        )
+
+
+class File(base.CommandBase):
+    subcommands = (Send, Request, Receive, Get, Upload, Share)
+
+    def __init__(self, host):
+        super(File, self).__init__(
+            host, "file", use_profile=False, help=_("files sending/receiving/management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_forums.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.jp import common
+from libervia.backend.tools.common.ansi import ANSI as A
+import codecs
+import json
+
+__commands__ = ["Forums"]
+
+FORUMS_TMP_DIR = "forums"
+
+
+class Edit(base.CommandBase, common.BaseEdit):
+    use_items = False
+
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "edit",
+            use_pubsub=True,
+            use_draft=True,
+            use_verbose=True,
+            help=_("edit forums"),
+        )
+        common.BaseEdit.__init__(self, self.host, FORUMS_TMP_DIR)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            default="",
+            help=_("forum key (DEFAULT: default forums)"),
+        )
+
+    def get_tmp_suff(self):
+        """return suffix used for content file"""
+        return "json"
+
+    async def publish(self, forums_raw):
+        try:
+            await self.host.bridge.forums_set(
+                forums_raw,
+                self.args.service,
+                self.args.node,
+                self.args.key,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set forums: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("forums have been edited"), 1)
+            self.host.quit()
+
+    async def start(self):
+        try:
+            forums_json = await self.host.bridge.forums_get(
+                self.args.service,
+                self.args.node,
+                self.args.key,
+                self.profile,
+            )
+        except Exception as e:
+            if e.classname == "NotFound":
+                forums_json = ""
+            else:
+                self.disp(f"can't get node configuration: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        content_file_obj, content_file_path = self.get_tmp_file()
+        forums_json = forums_json.strip()
+        if forums_json:
+            # we loads and dumps to have pretty printed json
+            forums = json.loads(forums_json)
+            # cf. https://stackoverflow.com/a/18337754
+            f = codecs.getwriter("utf-8")(content_file_obj)
+            json.dump(forums, f, ensure_ascii=False, indent=4)
+            content_file_obj.seek(0)
+        await self.run_editor("forums_editor_args", content_file_path, content_file_obj)
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_COMPLEX,
+            extra_outputs=extra_outputs,
+            use_pubsub=True,
+            use_verbose=True,
+            help=_("get forums structure"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            default="",
+            help=_("forum key (DEFAULT: default forums)"),
+        )
+
+    def default_output(self, forums, level=0):
+        for forum in forums:
+            keys = list(forum.keys())
+            keys.sort()
+            try:
+                keys.remove("title")
+            except ValueError:
+                pass
+            else:
+                keys.insert(0, "title")
+            try:
+                keys.remove("sub-forums")
+            except ValueError:
+                pass
+            else:
+                keys.append("sub-forums")
+
+            for key in keys:
+                value = forum[key]
+                if key == "sub-forums":
+                    self.default_output(value, level + 1)
+                else:
+                    if self.host.verbosity < 1 and key != "title":
+                        continue
+                    head_color = C.A_LEVEL_COLORS[level % len(C.A_LEVEL_COLORS)]
+                    self.disp(
+                        A.color(level * 4 * " ", head_color, key, A.RESET, ": ", value)
+                    )
+
+    async def start(self):
+        try:
+            forums_raw = await self.host.bridge.forums_get(
+                self.args.service,
+                self.args.node,
+                self.args.key,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get forums: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if not forums_raw:
+                self.disp(_("no schema found"), 1)
+                self.host.quit(1)
+            forums = json.loads(forums_raw)
+            await self.output(forums)
+            self.host.quit()
+
+
+class Forums(base.CommandBase):
+    subcommands = (Get, Edit)
+
+    def __init__(self, host):
+        super(Forums, self).__init__(
+            host, "forums", use_profile=False, help=_("Forums structure edition")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_identity.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.tools.common import data_format
+
+__commands__ = ["Identity"]
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            use_verbose=True,
+            help=_("get identity data"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--no-cache", action="store_true", help=_("do no use cached values")
+        )
+        self.parser.add_argument(
+            "jid", help=_("entity to check")
+        )
+
+    async def start(self):
+        jid_ = (await self.host.check_jids([self.args.jid]))[0]
+        try:
+            data = await self.host.bridge.identity_get(
+                jid_,
+                [],
+                not self.args.no_cache,
+                self.profile
+            )
+        except Exception as e:
+            self.disp(f"can't get identity data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            data = data_format.deserialise(data)
+            await self.output(data)
+            self.host.quit()
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        super(Set, self).__init__(host, "set", help=_("update identity data"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-n",
+            "--nickname",
+            action="append",
+            metavar="NICKNAME",
+            dest="nicknames",
+            help=_("nicknames of the entity"),
+        )
+        self.parser.add_argument(
+            "-d",
+            "--description",
+            help=_("description of the entity"),
+        )
+
+    async def start(self):
+        id_data = {}
+        for field in ("nicknames", "description"):
+            value = getattr(self.args, field)
+            if value is not None:
+                id_data[field] = value
+        if not id_data:
+            self.parser.error("At least one metadata must be set")
+        try:
+            self.host.bridge.identity_set(
+                data_format.serialise(id_data),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set identity data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Identity(base.CommandBase):
+    subcommands = (Get, Set)
+
+    def __init__(self, host):
+        super(Identity, self).__init__(
+            host, "identity", use_profile=False, help=_("identity management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_info.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,386 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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 pprint import pformat
+
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format, date_utils
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.frontends.jp import common
+from libervia.frontends.jp.constants import Const as C
+
+from . import base
+
+__commands__ = ["Info"]
+
+
+class Disco(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        super(Disco, self).__init__(
+            host,
+            "disco",
+            use_output="complex",
+            extra_outputs=extra_outputs,
+            help=_("service discovery"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("jid", help=_("entity to discover"))
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            type=str,
+            choices=("infos", "items", "both", "external", "all"),
+            default="all",
+            help=_("type of data to discover"),
+        )
+        self.parser.add_argument("-n", "--node", default="", help=_("node to use"))
+        self.parser.add_argument(
+            "-C",
+            "--no-cache",
+            dest="use_cache",
+            action="store_false",
+            help=_("ignore cache"),
+        )
+
+    def default_output(self, data):
+        features = data.get("features", [])
+        identities = data.get("identities", [])
+        extensions = data.get("extensions", {})
+        items = data.get("items", [])
+        external = data.get("external", [])
+
+        identities_table = common.Table(
+            self.host,
+            identities,
+            headers=(_("category"), _("type"), _("name")),
+            use_buffer=True,
+        )
+
+        extensions_tpl = []
+        extensions_types = list(extensions.keys())
+        extensions_types.sort()
+        for type_ in extensions_types:
+            fields = []
+            for field in extensions[type_]:
+                field_lines = []
+                data, values = field
+                data_keys = list(data.keys())
+                data_keys.sort()
+                for key in data_keys:
+                    field_lines.append(
+                        A.color("\t", C.A_SUBHEADER, key, A.RESET, ": ", data[key])
+                    )
+                if len(values) == 1:
+                    field_lines.append(
+                        A.color(
+                            "\t",
+                            C.A_SUBHEADER,
+                            "value",
+                            A.RESET,
+                            ": ",
+                            values[0] or (A.BOLD + "UNSET"),
+                        )
+                    )
+                elif len(values) > 1:
+                    field_lines.append(
+                        A.color("\t", C.A_SUBHEADER, "values", A.RESET, ": ")
+                    )
+
+                    for value in values:
+                        field_lines.append(A.color("\t  - ", A.BOLD, value))
+                fields.append("\n".join(field_lines))
+            extensions_tpl.append(
+                "{type_}\n{fields}".format(type_=type_, fields="\n\n".join(fields))
+            )
+
+        items_table = common.Table(
+            self.host, items, headers=(_("entity"), _("node"), _("name")), use_buffer=True
+        )
+
+        template = []
+        fmt_kwargs = {}
+        if features:
+            template.append(A.color(C.A_HEADER, _("Features")) + "\n\n{features}")
+        if identities:
+            template.append(A.color(C.A_HEADER, _("Identities")) + "\n\n{identities}")
+        if extensions:
+            template.append(A.color(C.A_HEADER, _("Extensions")) + "\n\n{extensions}")
+        if items:
+            template.append(A.color(C.A_HEADER, _("Items")) + "\n\n{items}")
+        if external:
+            fmt_lines = []
+            for e in external:
+                data = {k: e[k] for k in sorted(e)}
+                host = data.pop("host")
+                type_ = data.pop("type")
+                fmt_lines.append(A.color(
+                    "\t",
+                    C.A_SUBHEADER,
+                    host,
+                    " ",
+                    A.RESET,
+                    "[",
+                    C.A_LEVEL_COLORS[1],
+                    type_,
+                    A.RESET,
+                    "]",
+                ))
+                extended = data.pop("extended", None)
+                for key, value in data.items():
+                    fmt_lines.append(A.color(
+                        "\t\t",
+                        C.A_LEVEL_COLORS[2],
+                        f"{key}: ",
+                        C.A_LEVEL_COLORS[3],
+                        str(value)
+                    ))
+                if extended:
+                    fmt_lines.append(A.color(
+                        "\t\t",
+                        C.A_HEADER,
+                        "extended",
+                    ))
+                    nb_extended = len(extended)
+                    for idx, form_data in enumerate(extended):
+                        namespace = form_data.get("namespace")
+                        if namespace:
+                            fmt_lines.append(A.color(
+                                "\t\t",
+                                C.A_LEVEL_COLORS[2],
+                                "namespace: ",
+                                C.A_LEVEL_COLORS[3],
+                                A.BOLD,
+                                namespace
+                            ))
+                        for field_data in form_data["fields"]:
+                            name = field_data.get("name")
+                            if not name:
+                                continue
+                            field_type = field_data.get("type")
+                            if "multi" in field_type:
+                                value = ", ".join(field_data.get("values") or [])
+                            else:
+                                value = field_data.get("value")
+                                if value is None:
+                                    continue
+                                if field_type == "boolean":
+                                    value = C.bool(value)
+                            fmt_lines.append(A.color(
+                                "\t\t",
+                                C.A_LEVEL_COLORS[2],
+                                f"{name}: ",
+                                C.A_LEVEL_COLORS[3],
+                                A.BOLD,
+                                str(value)
+                            ))
+                        if nb_extended>1 and idx < nb_extended-1:
+                            fmt_lines.append("\n")
+
+                fmt_lines.append("\n")
+
+            template.append(
+                A.color(C.A_HEADER, _("External")) + "\n\n{external_formatted}"
+            )
+            fmt_kwargs["external_formatted"] = "\n".join(fmt_lines)
+
+        print(
+            "\n\n".join(template).format(
+                features="\n".join(features),
+                identities=identities_table.display().string,
+                extensions="\n".join(extensions_tpl),
+                items=items_table.display().string,
+                **fmt_kwargs,
+            )
+        )
+
+    async def start(self):
+        infos_requested = self.args.type in ("infos", "both", "all")
+        items_requested = self.args.type in ("items", "both", "all")
+        exter_requested = self.args.type in ("external", "all")
+        if self.args.node:
+            if self.args.type == "external":
+                self.parser.error(
+                    '--node can\'t be used with discovery of external services '
+                    '(--type="external")'
+                )
+            else:
+                exter_requested = False
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+        data = {}
+
+        # infos
+        if infos_requested:
+            try:
+                infos = await self.host.bridge.disco_infos(
+                    jid,
+                    node=self.args.node,
+                    use_cache=self.args.use_cache,
+                    profile_key=self.host.profile,
+                )
+            except Exception as e:
+                self.disp(_("error while doing discovery: {e}").format(e=e), error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            else:
+                features, identities, extensions = infos
+                features.sort()
+                identities.sort(key=lambda identity: identity[2])
+                data.update(
+                    {"features": features, "identities": identities, "extensions": extensions}
+                )
+
+        # items
+        if items_requested:
+            try:
+                items = await self.host.bridge.disco_items(
+                    jid,
+                    node=self.args.node,
+                    use_cache=self.args.use_cache,
+                    profile_key=self.host.profile,
+                )
+            except Exception as e:
+                self.disp(_("error while doing discovery: {e}").format(e=e), error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                items.sort(key=lambda item: item[2])
+                data["items"] = items
+
+        # external
+        if exter_requested:
+            try:
+                ext_services_s = await self.host.bridge.external_disco_get(
+                    jid,
+                    self.host.profile,
+                )
+            except Exception as e:
+                self.disp(
+                    _("error while doing external service discovery: {e}").format(e=e),
+                    error=True
+                )
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                data["external"] = data_format.deserialise(
+                    ext_services_s, type_check=list
+                )
+
+        # output
+        await self.output(data)
+        self.host.quit()
+
+
+class Version(base.CommandBase):
+    def __init__(self, host):
+        super(Version, self).__init__(host, "version", help=_("software version"))
+
+    def add_parser_options(self):
+        self.parser.add_argument("jid", type=str, help=_("Entity to request"))
+
+    async def start(self):
+        jids = await self.host.check_jids([self.args.jid])
+        jid = jids[0]
+        try:
+            data = await self.host.bridge.software_version_get(jid, self.host.profile)
+        except Exception as e:
+            self.disp(_("error while trying to get version: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            infos = []
+            name, version, os = data
+            if name:
+                infos.append(_("Software name: {name}").format(name=name))
+            if version:
+                infos.append(_("Software version: {version}").format(version=version))
+            if os:
+                infos.append(_("Operating System: {os}").format(os=os))
+
+            print("\n".join(infos))
+            self.host.quit()
+
+
+class Session(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        super(Session, self).__init__(
+            host,
+            "session",
+            use_output="dict",
+            extra_outputs=extra_outputs,
+            help=_("running session"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def default_output(self, data):
+        started = data["started"]
+        data["started"] = "{short} (UTC, {relative})".format(
+            short=date_utils.date_fmt(started),
+            relative=date_utils.date_fmt(started, "relative"),
+        )
+        await self.host.output(C.OUTPUT_DICT, "simple", {}, data)
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.session_infos_get(self.host.profile)
+        except Exception as e:
+            self.disp(_("Error getting session infos: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(data)
+            self.host.quit()
+
+
+class Devices(base.CommandBase):
+    def __init__(self, host):
+        super(Devices, self).__init__(
+            host, "devices", use_output=C.OUTPUT_LIST_DICT, help=_("devices of an entity")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid", type=str, nargs="?", default="", help=_("Entity to request")
+        )
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.devices_infos_get(
+                self.args.jid, self.host.profile
+            )
+        except Exception as e:
+            self.disp(_("Error getting devices infos: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            data = data_format.deserialise(data, type_check=list)
+            await self.output(data)
+            self.host.quit()
+
+
+class Info(base.CommandBase):
+    subcommands = (Disco, Version, Session, Devices)
+
+    def __init__(self, host):
+        super(Info, self).__init__(
+            host,
+            "info",
+            use_profile=False,
+            help=_("Get various pieces of information on entities"),
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_input.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+
+import subprocess
+import argparse
+import sys
+import shlex
+import asyncio
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+
+__commands__ = ["Input"]
+OPT_STDIN = "stdin"
+OPT_SHORT = "short"
+OPT_LONG = "long"
+OPT_POS = "positional"
+OPT_IGNORE = "ignore"
+OPT_TYPES = (OPT_STDIN, OPT_SHORT, OPT_LONG, OPT_POS, OPT_IGNORE)
+OPT_EMPTY_SKIP = "skip"
+OPT_EMPTY_IGNORE = "ignore"
+OPT_EMPTY_CHOICES = (OPT_EMPTY_SKIP, OPT_EMPTY_IGNORE)
+
+
+class InputCommon(base.CommandBase):
+    def __init__(self, host, name, help):
+        base.CommandBase.__init__(
+            self, host, name, use_verbose=True, use_profile=False, help=help
+        )
+        self.idx = 0
+        self.reset()
+
+    def reset(self):
+        self.args_idx = 0
+        self._stdin = []
+        self._opts = []
+        self._pos = []
+        self._values_ori = []
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--encoding", default="utf-8", help=_("encoding of the input data")
+        )
+        self.parser.add_argument(
+            "-i",
+            "--stdin",
+            action="append_const",
+            const=(OPT_STDIN, None),
+            dest="arguments",
+            help=_("standard input"),
+        )
+        self.parser.add_argument(
+            "-s",
+            "--short",
+            type=self.opt(OPT_SHORT),
+            action="append",
+            dest="arguments",
+            help=_("short option"),
+        )
+        self.parser.add_argument(
+            "-l",
+            "--long",
+            type=self.opt(OPT_LONG),
+            action="append",
+            dest="arguments",
+            help=_("long option"),
+        )
+        self.parser.add_argument(
+            "-p",
+            "--positional",
+            type=self.opt(OPT_POS),
+            action="append",
+            dest="arguments",
+            help=_("positional argument"),
+        )
+        self.parser.add_argument(
+            "-x",
+            "--ignore",
+            action="append_const",
+            const=(OPT_IGNORE, None),
+            dest="arguments",
+            help=_("ignore value"),
+        )
+        self.parser.add_argument(
+            "-D",
+            "--debug",
+            action="store_true",
+            help=_("don't actually run commands but echo what would be launched"),
+        )
+        self.parser.add_argument(
+            "--log", type=argparse.FileType("w"), help=_("log stdout to FILE")
+        )
+        self.parser.add_argument(
+            "--log-err", type=argparse.FileType("w"), help=_("log stderr to FILE")
+        )
+        self.parser.add_argument("command", nargs=argparse.REMAINDER)
+
+    def opt(self, type_):
+        return lambda s: (type_, s)
+
+    def add_value(self, value):
+        """add a parsed value according to arguments sequence"""
+        self._values_ori.append(value)
+        arguments = self.args.arguments
+        try:
+            arg_type, arg_name = arguments[self.args_idx]
+        except IndexError:
+            self.disp(
+                _("arguments in input data and in arguments sequence don't match"),
+                error=True,
+            )
+            self.host.quit(C.EXIT_DATA_ERROR)
+        self.args_idx += 1
+        while self.args_idx < len(arguments):
+            next_arg = arguments[self.args_idx]
+            if next_arg[0] not in OPT_TYPES:
+                # value will not be used if False or None, so we skip filter
+                if value not in (False, None):
+                    # we have a filter
+                    filter_type, filter_arg = arguments[self.args_idx]
+                    value = self.filter(filter_type, filter_arg, value)
+            else:
+                break
+            self.args_idx += 1
+
+        if value is None:
+            # we ignore this argument
+            return
+
+        if value is False:
+            # we skip the whole row
+            if self.args.debug:
+                self.disp(
+                    A.color(
+                        C.A_SUBHEADER,
+                        _("values: "),
+                        A.RESET,
+                        ", ".join(self._values_ori),
+                    ),
+                    2,
+                )
+                self.disp(A.color(A.BOLD, _("**SKIPPING**\n")))
+            self.reset()
+            self.idx += 1
+            raise exceptions.CancelError
+
+        if not isinstance(value, list):
+            value = [value]
+
+        for v in value:
+            if arg_type == OPT_STDIN:
+                self._stdin.append(v)
+            elif arg_type == OPT_SHORT:
+                self._opts.append("-{}".format(arg_name))
+                self._opts.append(v)
+            elif arg_type == OPT_LONG:
+                self._opts.append("--{}".format(arg_name))
+                self._opts.append(v)
+            elif arg_type == OPT_POS:
+                self._pos.append(v)
+            elif arg_type == OPT_IGNORE:
+                pass
+            else:
+                self.parser.error(
+                    _(
+                        "Invalid argument, an option type is expected, got {type_}:{name}"
+                    ).format(type_=arg_type, name=arg_name)
+                )
+
+    async def runCommand(self):
+        """run requested command with parsed arguments"""
+        if self.args_idx != len(self.args.arguments):
+            self.disp(
+                _("arguments in input data and in arguments sequence don't match"),
+                error=True,
+            )
+            self.host.quit(C.EXIT_DATA_ERROR)
+        end = '\n' if self.args.debug else ' '
+        self.disp(
+            A.color(C.A_HEADER, _("command {idx}").format(idx=self.idx)),
+            end = end,
+        )
+        stdin = "".join(self._stdin)
+        if self.args.debug:
+            self.disp(
+                A.color(
+                    C.A_SUBHEADER,
+                    _("values: "),
+                    A.RESET,
+                    ", ".join([shlex.quote(a) for a in self._values_ori])
+                ),
+                2,
+            )
+
+            if stdin:
+                self.disp(A.color(C.A_SUBHEADER, "--- STDIN ---"))
+                self.disp(stdin)
+                self.disp(A.color(C.A_SUBHEADER, "-------------"))
+
+            self.disp(
+                "{indent}{prog} {static} {options} {positionals}".format(
+                    indent=4 * " ",
+                    prog=sys.argv[0],
+                    static=" ".join(self.args.command),
+                    options=" ".join(shlex.quote(o) for o in self._opts),
+                    positionals=" ".join(shlex.quote(p) for p in self._pos),
+                )
+            )
+            self.disp("\n")
+        else:
+            self.disp(" (" + ", ".join(self._values_ori) + ")", 2, end=' ')
+            args = [sys.argv[0]] + self.args.command + self._opts + self._pos
+            p = await asyncio.create_subprocess_exec(
+                *args,
+                stdin=subprocess.PIPE,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+            )
+            stdout, stderr = await p.communicate(stdin.encode('utf-8'))
+            log = self.args.log
+            log_err = self.args.log_err
+            log_tpl = "{command}\n{buff}\n\n"
+            if log:
+                log.write(log_tpl.format(
+                    command=" ".join(shlex.quote(a) for a in args),
+                    buff=stdout.decode('utf-8', 'replace')))
+            if log_err:
+                log_err.write(log_tpl.format(
+                    command=" ".join(shlex.quote(a) for a in args),
+                    buff=stderr.decode('utf-8', 'replace')))
+            ret = p.returncode
+            if ret == 0:
+                self.disp(A.color(C.A_SUCCESS, _("OK")))
+            else:
+                self.disp(A.color(C.A_FAILURE, _("FAILED")))
+
+        self.reset()
+        self.idx += 1
+
+    def filter(self, filter_type, filter_arg, value):
+        """change input value
+
+        @param filter_type(unicode): name of the filter
+        @param filter_arg(unicode, None): argument of the filter
+        @param value(unicode): value to filter
+        @return (unicode, False, None): modified value
+            False to skip the whole row
+            None to ignore this argument (but continue row with other ones)
+        """
+        raise NotImplementedError
+
+
+class Csv(InputCommon):
+    def __init__(self, host):
+        super(Csv, self).__init__(host, "csv", _("comma-separated values"))
+
+    def add_parser_options(self):
+        InputCommon.add_parser_options(self)
+        self.parser.add_argument(
+            "-r",
+            "--row",
+            type=int,
+            default=0,
+            help=_("starting row (previous ones will be ignored)"),
+        )
+        self.parser.add_argument(
+            "-S",
+            "--split",
+            action="append_const",
+            const=("split", None),
+            dest="arguments",
+            help=_("split value in several options"),
+        )
+        self.parser.add_argument(
+            "-E",
+            "--empty",
+            action="append",
+            type=self.opt("empty"),
+            dest="arguments",
+            help=_("action to do on empty value ({choices})").format(
+                choices=", ".join(OPT_EMPTY_CHOICES)
+            ),
+        )
+
+    def filter(self, filter_type, filter_arg, value):
+        if filter_type == "split":
+            return value.split()
+        elif filter_type == "empty":
+            if filter_arg == OPT_EMPTY_IGNORE:
+                return value if value else None
+            elif filter_arg == OPT_EMPTY_SKIP:
+                return value if value else False
+            else:
+                self.parser.error(
+                    _("--empty value must be one of {choices}").format(
+                        choices=", ".join(OPT_EMPTY_CHOICES)
+                    )
+                )
+
+        super(Csv, self).filter(filter_type, filter_arg, value)
+
+    async def start(self):
+        import csv
+
+        if self.args.encoding:
+            sys.stdin.reconfigure(encoding=self.args.encoding, errors="replace")
+        reader = csv.reader(sys.stdin)
+        for idx, row in enumerate(reader):
+            try:
+                if idx < self.args.row:
+                    continue
+                for value in row:
+                    self.add_value(value)
+                await self.runCommand()
+            except exceptions.CancelError:
+                #  this row has been cancelled, we skip it
+                continue
+
+        self.host.quit()
+
+
+class Input(base.CommandBase):
+    subcommands = (Csv,)
+
+    def __init__(self, host):
+        super(Input, self).__init__(
+            host,
+            "input",
+            use_profile=False,
+            help=_("launch command with external input"),
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_invitation.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,371 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import data_format
+
+__commands__ = ["Invitation"]
+
+
+class Create(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "create",
+            use_profile=False,
+            use_output=C.OUTPUT_DICT,
+            help=_("create and send an invitation"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j",
+            "--jid",
+            default="",
+            help="jid of the invitee (default: generate one)",
+        )
+        self.parser.add_argument(
+            "-P",
+            "--password",
+            default="",
+            help="password of the invitee profile/XMPP account (default: generate one)",
+        )
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help="name of the invitee",
+        )
+        self.parser.add_argument(
+            "-N",
+            "--host-name",
+            default="",
+            help="name of the host",
+        )
+        self.parser.add_argument(
+            "-e",
+            "--email",
+            action="append",
+            default=[],
+            help="email(s) to send the invitation to (if --no-email is set, email will just be saved)",
+        )
+        self.parser.add_argument(
+            "--no-email", action="store_true", help="do NOT send invitation email"
+        )
+        self.parser.add_argument(
+            "-l",
+            "--lang",
+            default="",
+            help="main language spoken by the invitee",
+        )
+        self.parser.add_argument(
+            "-u",
+            "--url",
+            default="",
+            help="template to construct the URL",
+        )
+        self.parser.add_argument(
+            "-s",
+            "--subject",
+            default="",
+            help="subject of the invitation email (default: generic subject)",
+        )
+        self.parser.add_argument(
+            "-b",
+            "--body",
+            default="",
+            help="body of the invitation email (default: generic body)",
+        )
+        self.parser.add_argument(
+            "-x",
+            "--extra",
+            metavar=("KEY", "VALUE"),
+            action="append",
+            nargs=2,
+            default=[],
+            help="extra data to associate with invitation/invitee",
+        )
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            default="",
+            help="profile doing the invitation (default: don't associate profile)",
+        )
+
+    async def start(self):
+        extra = dict(self.args.extra)
+        email = self.args.email[0] if self.args.email else None
+        emails_extra = self.args.email[1:]
+        if self.args.no_email:
+            if email:
+                extra["email"] = email
+                data_format.iter2dict("emails_extra", emails_extra)
+        else:
+            if not email:
+                self.parser.error(
+                    _("you need to specify an email address to send email invitation")
+                )
+
+        try:
+            invitation_data = await self.host.bridge.invitation_create(
+                email,
+                emails_extra,
+                self.args.jid,
+                self.args.password,
+                self.args.name,
+                self.args.host_name,
+                self.args.lang,
+                self.args.url,
+                self.args.subject,
+                self.args.body,
+                extra,
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create invitation: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(invitation_data)
+            self.host.quit(C.EXIT_OK)
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_profile=False,
+            use_output=C.OUTPUT_DICT,
+            help=_("get invitation data"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("id", help=_("invitation UUID"))
+        self.parser.add_argument(
+            "-j",
+            "--with-jid",
+            action="store_true",
+            help=_("start profile session and retrieve jid"),
+        )
+
+    async def output_data(self, data, jid_=None):
+        if jid_ is not None:
+            data["jid"] = jid_
+        await self.output(data)
+        self.host.quit()
+
+    async def start(self):
+        try:
+            invitation_data = await self.host.bridge.invitation_get(
+                self.args.id,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't get invitation data: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if not self.args.with_jid:
+            await self.output_data(invitation_data)
+        else:
+            profile = invitation_data["guest_profile"]
+            try:
+                await self.host.bridge.profile_start_session(
+                    invitation_data["password"],
+                    profile,
+                )
+            except Exception as e:
+                self.disp(msg=_("can't start session: {e}").format(e=e), error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            try:
+                jid_ = await self.host.bridge.param_get_a_async(
+                    "JabberID",
+                    "Connection",
+                    profile_key=profile,
+                )
+            except Exception as e:
+                self.disp(msg=_("can't retrieve jid: {e}").format(e=e), error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            await self.output_data(invitation_data, jid_)
+
+
+class Delete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_profile=False,
+            help=_("delete guest account"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("id", help=_("invitation UUID"))
+
+    async def start(self):
+        try:
+            await self.host.bridge.invitation_delete(
+                self.args.id,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't delete guest account: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+
+class Modify(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "modify", use_profile=False, help=_("modify existing invitation")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--replace", action="store_true", help="replace the whole data"
+        )
+        self.parser.add_argument(
+            "-n",
+            "--name",
+            default="",
+            help="name of the invitee",
+        )
+        self.parser.add_argument(
+            "-N",
+            "--host-name",
+            default="",
+            help="name of the host",
+        )
+        self.parser.add_argument(
+            "-e",
+            "--email",
+            default="",
+            help="email to send the invitation to (if --no-email is set, email will just be saved)",
+        )
+        self.parser.add_argument(
+            "-l",
+            "--lang",
+            dest="language",
+            default="",
+            help="main language spoken by the invitee",
+        )
+        self.parser.add_argument(
+            "-x",
+            "--extra",
+            metavar=("KEY", "VALUE"),
+            action="append",
+            nargs=2,
+            default=[],
+            help="extra data to associate with invitation/invitee",
+        )
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            default="",
+            help="profile doing the invitation (default: don't associate profile",
+        )
+        self.parser.add_argument("id", help=_("invitation UUID"))
+
+    async def start(self):
+        extra = dict(self.args.extra)
+        for arg_name in ("name", "host_name", "email", "language", "profile"):
+            value = getattr(self.args, arg_name)
+            if not value:
+                continue
+            if arg_name in extra:
+                self.parser.error(
+                    _(
+                        "you can't set {arg_name} in both optional argument and extra"
+                    ).format(arg_name=arg_name)
+                )
+            extra[arg_name] = value
+        try:
+            await self.host.bridge.invitation_modify(
+                self.args.id,
+                extra,
+                self.args.replace,
+            )
+        except Exception as e:
+            self.disp(f"can't modify invitation: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("invitations have been modified successfuly"))
+            self.host.quit(C.EXIT_OK)
+
+
+class List(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {"default": self.default_output}
+        base.CommandBase.__init__(
+            self,
+            host,
+            "list",
+            use_profile=False,
+            use_output=C.OUTPUT_COMPLEX,
+            extra_outputs=extra_outputs,
+            help=_("list invitations data"),
+        )
+
+    def default_output(self, data):
+        for idx, datum in enumerate(data.items()):
+            if idx:
+                self.disp("\n")
+            key, invitation_data = datum
+            self.disp(A.color(C.A_HEADER, key))
+            indent = "  "
+            for k, v in invitation_data.items():
+                self.disp(indent + A.color(C.A_SUBHEADER, k + ":") + " " + str(v))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            default=C.PROF_KEY_NONE,
+            help=_("return only invitations linked to this profile"),
+        )
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.invitation_list(
+                self.args.profile,
+            )
+        except Exception as e:
+            self.disp(f"return only invitations linked to this profile: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(data)
+            self.host.quit()
+
+
+class Invitation(base.CommandBase):
+    subcommands = (Create, Get, Delete, Modify, List)
+
+    def __init__(self, host):
+        super(Invitation, self).__init__(
+            host,
+            "invitation",
+            use_profile=False,
+            help=_("invitation of user(s) without XMPP account"),
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_list.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+
+import json
+import os
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.frontends.jp import common
+from libervia.frontends.jp.constants import Const as C
+from . import base
+
+__commands__ = ["List"]
+
+FIELDS_MAP = "mapping"
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_verbose=True,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS},
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            use_output=C.OUTPUT_LIST_XMLUI,
+            help=_("get lists"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
+        try:
+            lists_data = data_format.deserialise(
+                await self.host.bridge.list_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    "",
+                    self.get_pubsub_extra(),
+                    self.profile,
+                ),
+                type_check=list,
+            )
+        except Exception as e:
+            self.disp(f"can't get lists: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(lists_data[0])
+            self.host.quit(C.EXIT_OK)
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("set a list item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs="+",
+            dest="fields",
+            required=True,
+            metavar=("NAME", "VALUES"),
+            help=_("field(s) to set (required)"),
+        )
+        self.parser.add_argument(
+            "-U",
+            "--update",
+            choices=("auto", "true", "false"),
+            default="auto",
+            help=_("update existing item instead of replacing it (DEFAULT: auto)"),
+        )
+        self.parser.add_argument(
+            "item",
+            nargs="?",
+            default="",
+            help=_("id, URL of the item to update, or nothing for new item"),
+        )
+
+    async def start(self):
+        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
+        if self.args.update == "auto":
+            # we update if we have a item id specified
+            update = bool(self.args.item)
+        else:
+            update = C.bool(self.args.update)
+
+        values = {}
+
+        for field_data in self.args.fields:
+            values.setdefault(field_data[0], []).extend(field_data[1:])
+
+        extra = {"update": update}
+
+        try:
+            item_id = await self.host.bridge.list_set(
+                self.args.service,
+                self.args.node,
+                values,
+                "",
+                self.args.item,
+                data_format.serialise(extra),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set list item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(f"item {str(item_id or self.args.item)!r} set successfully")
+            self.host.quit(C.EXIT_OK)
+
+
+class Delete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_pubsub=True,
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("delete a list item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--force", action="store_true", help=_("delete without confirmation")
+        )
+        self.parser.add_argument(
+            "-N", "--notify", action="store_true", help=_("notify deletion")
+        )
+        self.parser.add_argument(
+            "item",
+            help=_("id of the item to delete"),
+        )
+
+    async def start(self):
+        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
+        if not self.args.item:
+            self.parser.error(_("You need to specify a list item to delete"))
+        if not self.args.force:
+            message = _("Are you sure to delete list item {item_id} ?").format(
+                item_id=self.args.item
+            )
+            await self.host.confirm_or_quit(message, _("item deletion cancelled"))
+        try:
+            await self.host.bridge.list_delete_item(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.notify,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't delete item: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("item {item} has been deleted").format(item=self.args.item))
+            self.host.quit(C.EXIT_OK)
+
+
+class Import(base.CommandBase):
+    # TODO: factorize with blog/import
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "import",
+            use_progress=True,
+            use_verbose=True,
+            help=_("import tickets from external software/dataset"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "importer",
+            nargs="?",
+            help=_("importer name, nothing to display importers list"),
+        )
+        self.parser.add_argument(
+            "-o",
+            "--option",
+            action="append",
+            nargs=2,
+            default=[],
+            metavar=("NAME", "VALUE"),
+            help=_("importer specific options (see importer description)"),
+        )
+        self.parser.add_argument(
+            "-m",
+            "--map",
+            action="append",
+            nargs=2,
+            default=[],
+            metavar=("IMPORTED_FIELD", "DEST_FIELD"),
+            help=_(
+                "specified field in import data will be put in dest field (default: use "
+                "same field name, or ignore if it doesn't exist)"
+            ),
+        )
+        self.parser.add_argument(
+            "-s",
+            "--service",
+            default="",
+            metavar="PUBSUB_SERVICE",
+            help=_("PubSub service where the items must be uploaded (default: server)"),
+        )
+        self.parser.add_argument(
+            "-n",
+            "--node",
+            default="",
+            metavar="PUBSUB_NODE",
+            help=_(
+                "PubSub node where the items must be uploaded (default: tickets' "
+                "defaults)"
+            ),
+        )
+        self.parser.add_argument(
+            "location",
+            nargs="?",
+            help=_(
+                "importer data location (see importer description), nothing to show "
+                "importer description"
+            ),
+        )
+
+    async def on_progress_started(self, metadata):
+        self.disp(_("Tickets upload started"), 2)
+
+    async def on_progress_finished(self, metadata):
+        self.disp(_("Tickets uploaded successfully"), 2)
+
+    async def on_progress_error(self, error_msg):
+        self.disp(
+            _("Error while uploading tickets: {error_msg}").format(error_msg=error_msg),
+            error=True,
+        )
+
+    async def start(self):
+        if self.args.location is None:
+            # no location, the list of importer or description is requested
+            for name in ("option", "service", "node"):
+                if getattr(self.args, name):
+                    self.parser.error(
+                        _(
+                            "{name} argument can't be used without location argument"
+                        ).format(name=name)
+                    )
+            if self.args.importer is None:
+                self.disp(
+                    "\n".join(
+                        [
+                            f"{name}: {desc}"
+                            for name, desc in await self.host.bridge.ticketsImportList()
+                        ]
+                    )
+                )
+            else:
+                try:
+                    short_desc, long_desc = await self.host.bridge.ticketsImportDesc(
+                        self.args.importer
+                    )
+                except Exception as e:
+                    self.disp(f"can't get importer description: {e}", error=True)
+                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+                else:
+                    self.disp(f"{name}: {short_desc}\n\n{long_desc}")
+            self.host.quit()
+        else:
+            # we have a location, an import is requested
+
+            if self.args.progress:
+                # we use a custom progress bar template as we want a counter
+                self.pbar_template = [
+                    _("Progress: "),
+                    ["Percentage"],
+                    " ",
+                    ["Bar"],
+                    " ",
+                    ["Counter"],
+                    " ",
+                    ["ETA"],
+                ]
+
+            options = {key: value for key, value in self.args.option}
+            fields_map = dict(self.args.map)
+            if fields_map:
+                if FIELDS_MAP in options:
+                    self.parser.error(
+                        _(
+                            "fields_map must be specified either preencoded in --option or "
+                            "using --map, but not both at the same time"
+                        )
+                    )
+                options[FIELDS_MAP] = json.dumps(fields_map)
+
+            try:
+                progress_id = await self.host.bridge.ticketsImport(
+                    self.args.importer,
+                    self.args.location,
+                    options,
+                    self.args.service,
+                    self.args.node,
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(
+                    _("Error while trying to import tickets: {e}").format(e=e),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                await self.set_progress_id(progress_id)
+
+
+class List(base.CommandBase):
+    subcommands = (Get, Set, Delete, Import)
+
+    def __init__(self, host):
+        super(List, self).__init__(
+            host, "list", use_profile=False, help=_("pubsub lists handling")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_merge_request.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+
+import os.path
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.jp import xmlui_manager
+from libervia.frontends.jp import common
+
+__commands__ = ["MergeRequest"]
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("publish or update a merge request"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-i",
+            "--item",
+            default="",
+            help=_("id or URL of the request to update, or nothing for a new one"),
+        )
+        self.parser.add_argument(
+            "-r",
+            "--repository",
+            metavar="PATH",
+            default=".",
+            help=_("path of the repository (DEFAULT: current directory)"),
+        )
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("publish merge request without confirmation"),
+        )
+        self.parser.add_argument(
+            "-l",
+            "--label",
+            dest="labels",
+            action="append",
+            help=_("labels to categorize your request"),
+        )
+
+    async def start(self):
+        self.repository = os.path.expanduser(os.path.abspath(self.args.repository))
+        await common.fill_well_known_uri(self, self.repository, "merge requests")
+        if not self.args.force:
+            message = _(
+                "You are going to publish your changes to service "
+                "[{service}], are you sure ?"
+            ).format(service=self.args.service)
+            await self.host.confirm_or_quit(
+                message, _("merge request publication cancelled")
+            )
+
+        extra = {"update": True} if self.args.item else {}
+        values = {}
+        if self.args.labels is not None:
+            values["labels"] = self.args.labels
+        try:
+            published_id = await self.host.bridge.merge_request_set(
+                self.args.service,
+                self.args.node,
+                self.repository,
+                "auto",
+                values,
+                "",
+                self.args.item,
+                data_format.serialise(extra),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create merge requests: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if published_id:
+            self.disp(
+                _("Merge request published at {published_id}").format(
+                    published_id=published_id
+                )
+            )
+        else:
+            self.disp(_("Merge request published"))
+
+        self.host.quit(C.EXIT_OK)
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_verbose=True,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS},
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("get a merge request"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        await common.fill_well_known_uri(self, os.getcwd(), "merge requests", meta_map={})
+        extra = {}
+        try:
+            requests_data = data_format.deserialise(
+                await self.host.bridge.merge_requests_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    "",
+                    data_format.serialise(extra),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't get merge request: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if self.verbosity >= 1:
+            whitelist = None
+        else:
+            whitelist = {"id", "title", "body"}
+        for request_xmlui in requests_data["items"]:
+            xmlui = xmlui_manager.create(self.host, request_xmlui, whitelist=whitelist)
+            await xmlui.show(values_only=True)
+            self.disp("")
+        self.host.quit(C.EXIT_OK)
+
+
+class Import(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "import",
+            use_pubsub=True,
+            pubsub_flags={C.SINGLE_ITEM, C.ITEM},
+            pubsub_defaults={"service": _("auto"), "node": _("auto")},
+            help=_("import a merge request"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-r",
+            "--repository",
+            metavar="PATH",
+            default=".",
+            help=_("path of the repository (DEFAULT: current directory)"),
+        )
+
+    async def start(self):
+        self.repository = os.path.expanduser(os.path.abspath(self.args.repository))
+        await common.fill_well_known_uri(
+            self, self.repository, "merge requests", meta_map={}
+        )
+        extra = {}
+        try:
+            await self.host.bridge.merge_requests_import(
+                self.repository,
+                self.args.item,
+                self.args.service,
+                self.args.node,
+                extra,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't import merge request: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class MergeRequest(base.CommandBase):
+    subcommands = (Set, Get, Import)
+
+    def __init__(self, host):
+        super(MergeRequest, self).__init__(
+            host, "merge-request", use_profile=False, help=_("merge-request management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_message.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,327 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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 pathlib import Path
+import sys
+
+from twisted.python import filepath
+
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.utils import clean_ustr
+from libervia.frontends.jp import base
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.tools import jid
+
+
+__commands__ = ["Message"]
+
+
+class Send(base.CommandBase):
+    def __init__(self, host):
+        super(Send, self).__init__(host, "send", help=_("send a message to a contact"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-l", "--lang", type=str, default="", help=_("language of the message")
+        )
+        self.parser.add_argument(
+            "-s",
+            "--separate",
+            action="store_true",
+            help=_(
+                "separate xmpp messages: send one message per line instead of one "
+                "message alone."
+            ),
+        )
+        self.parser.add_argument(
+            "-n",
+            "--new-line",
+            action="store_true",
+            help=_(
+                "add a new line at the beginning of the input"
+            ),
+        )
+        self.parser.add_argument(
+            "-S",
+            "--subject",
+            help=_("subject of the message"),
+        )
+        self.parser.add_argument(
+            "-L", "--subject-lang", type=str, default="", help=_("language of subject")
+        )
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            choices=C.MESS_TYPE_STANDARD + (C.MESS_TYPE_AUTO,),
+            default=C.MESS_TYPE_AUTO,
+            help=_("type of the message"),
+        )
+        self.parser.add_argument("-e", "--encrypt", metavar="ALGORITHM",
+                                 help=_("encrypt message using given algorithm"))
+        self.parser.add_argument(
+            "--encrypt-noreplace",
+            action="store_true",
+            help=_("don't replace encryption algorithm if an other one is already used"))
+        self.parser.add_argument(
+            "-a", "--attach", dest="attachments", action="append", metavar="FILE_PATH",
+            help=_("add a file as an attachment")
+        )
+        syntax = self.parser.add_mutually_exclusive_group()
+        syntax.add_argument("-x", "--xhtml", action="store_true", help=_("XHTML body"))
+        syntax.add_argument("-r", "--rich", action="store_true", help=_("rich body"))
+        self.parser.add_argument(
+            "jid", help=_("the destination jid")
+        )
+
+    async def send_stdin(self, dest_jid):
+        """Send incomming data on stdin to jabber contact
+
+        @param dest_jid: destination jid
+        """
+        header = "\n" if self.args.new_line else ""
+        # FIXME: stdin is not read asynchronously at the moment
+        stdin_lines = [
+            stream for stream in sys.stdin.readlines()
+        ]
+        extra = {}
+        if self.args.subject is None:
+            subject = {}
+        else:
+            subject = {self.args.subject_lang: self.args.subject}
+
+        if self.args.xhtml or self.args.rich:
+            key = "xhtml" if self.args.xhtml else "rich"
+            if self.args.lang:
+                key = f"{key}_{self.args.lang}"
+            extra[key] = clean_ustr("".join(stdin_lines))
+            stdin_lines = []
+
+        to_send = []
+
+        error = False
+
+        if self.args.separate:
+            # we send stdin in several messages
+            if header:
+                # first we sent the header
+                try:
+                    await self.host.bridge.message_send(
+                        dest_jid,
+                        {self.args.lang: header},
+                        subject,
+                        self.args.type,
+                        profile_key=self.profile,
+                    )
+                except Exception as e:
+                    self.disp(f"can't send header: {e}", error=True)
+                    error = True
+
+            to_send.extend({self.args.lang: clean_ustr(l.replace("\n", ""))}
+                           for l in stdin_lines)
+        else:
+            # we sent all in a single message
+            if not (self.args.xhtml or self.args.rich):
+                msg = {self.args.lang: header + clean_ustr("".join(stdin_lines))}
+            else:
+                msg = {}
+            to_send.append(msg)
+
+        if self.args.attachments:
+            attachments = extra[C.KEY_ATTACHMENTS] = []
+            for attachment in self.args.attachments:
+                try:
+                    file_path = str(Path(attachment).resolve(strict=True))
+                except FileNotFoundError:
+                    self.disp("file {attachment} doesn't exists, ignoring", error=True)
+                else:
+                    attachments.append({"path": file_path})
+
+        for idx, msg in enumerate(to_send):
+            if idx > 0 and C.KEY_ATTACHMENTS in extra:
+                # if we send several messages, we only want to send attachments with the
+                # first one
+                del extra[C.KEY_ATTACHMENTS]
+            try:
+                await self.host.bridge.message_send(
+                    dest_jid,
+                    msg,
+                    subject,
+                    self.args.type,
+                    data_format.serialise(extra),
+                    profile_key=self.host.profile)
+            except Exception as e:
+                self.disp(f"can't send message {msg!r}: {e}", error=True)
+                error = True
+
+        if error:
+            # at least one message sending failed
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        self.host.quit()
+
+    async def start(self):
+        if self.args.xhtml and self.args.separate:
+            self.disp(
+                "argument -s/--separate is not compatible yet with argument -x/--xhtml",
+                error=True,
+            )
+            self.host.quit(C.EXIT_BAD_ARG)
+
+        jids = await self.host.check_jids([self.args.jid])
+        jid_ = jids[0]
+
+        if self.args.encrypt_noreplace and self.args.encrypt is None:
+            self.parser.error("You need to use --encrypt if you use --encrypt-noreplace")
+
+        if self.args.encrypt is not None:
+            try:
+                namespace = await self.host.bridge.encryption_namespace_get(
+                    self.args.encrypt)
+            except Exception as e:
+                self.disp(f"can't get encryption namespace: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+            try:
+                await self.host.bridge.message_encryption_start(
+                    jid_, namespace, not self.args.encrypt_noreplace, self.profile
+                )
+            except Exception as e:
+                self.disp(f"can't start encryption session: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        await self.send_stdin(jid_)
+
+
+class Retract(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(host, "retract", help=_("retract a message"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "message_id",
+            help=_("ID of the message (internal ID)")
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.message_retract(
+                self.args.message_id,
+                self.profile
+            )
+        except Exception as e:
+            self.disp(f"can't retract message: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                "message retraction has been requested, please note that this is a "
+                "request which can't be enforced (see documentation for details).")
+            self.host.quit(C.EXIT_OK)
+
+
+class MAM(base.CommandBase):
+
+    def __init__(self, host):
+        super(MAM, self).__init__(
+            host, "mam", use_output=C.OUTPUT_MESS, use_verbose=True,
+            help=_("query archives using MAM"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-s", "--service", default="",
+            help=_("jid of the service (default: profile's server"))
+        self.parser.add_argument(
+            "-S", "--start", dest="mam_start", type=base.date_decoder,
+            help=_(
+                "start fetching archive from this date (default: from the beginning)"))
+        self.parser.add_argument(
+            "-E", "--end", dest="mam_end", type=base.date_decoder,
+            help=_("end fetching archive after this date (default: no limit)"))
+        self.parser.add_argument(
+            "-W", "--with", dest="mam_with",
+            help=_("retrieve only archives with this jid"))
+        self.parser.add_argument(
+            "-m", "--max", dest="rsm_max", type=int, default=20,
+            help=_("maximum number of items to retrieve, using RSM (default: 20))"))
+        rsm_page_group = self.parser.add_mutually_exclusive_group()
+        rsm_page_group.add_argument(
+            "-a", "--after", dest="rsm_after",
+            help=_("find page after this item"), metavar='ITEM_ID')
+        rsm_page_group.add_argument(
+            "-b", "--before", dest="rsm_before",
+            help=_("find page before this item"), metavar='ITEM_ID')
+        rsm_page_group.add_argument(
+            "--index", dest="rsm_index", type=int,
+            help=_("index of the page to retrieve"))
+
+    async def start(self):
+        extra = {}
+        if self.args.mam_start is not None:
+            extra["mam_start"] = float(self.args.mam_start)
+        if self.args.mam_end is not None:
+            extra["mam_end"] = float(self.args.mam_end)
+        if self.args.mam_with is not None:
+            extra["mam_with"] = self.args.mam_with
+        for suff in ('max', 'after', 'before', 'index'):
+            key = 'rsm_' + suff
+            value = getattr(self.args,key)
+            if value is not None:
+                extra[key] = str(value)
+        try:
+            data, metadata_s, profile = await self.host.bridge.mam_get(
+                self.args.service, data_format.serialise(extra), self.profile)
+        except Exception as e:
+            self.disp(f"can't retrieve MAM archives: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        metadata = data_format.deserialise(metadata_s)
+
+        try:
+            session_info = await self.host.bridge.session_infos_get(self.profile)
+        except Exception as e:
+            self.disp(f"can't get session infos: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        # we need to fill own_jid for message output
+        self.host.own_jid = jid.JID(session_info["jid"])
+
+        await self.output(data)
+
+        # FIXME: metadata are not displayed correctly and don't play nice with output
+        #        they should be added to output data somehow
+        if self.verbosity:
+            for value in ("rsm_first", "rsm_last", "rsm_index", "rsm_count",
+                          "mam_complete", "mam_stable"):
+                if value in metadata:
+                    label = value.split("_")[1]
+                    self.disp(A.color(
+                        C.A_HEADER, label, ': ' , A.RESET, metadata[value]))
+
+        self.host.quit()
+
+
+class Message(base.CommandBase):
+    subcommands = (Send, Retract, MAM)
+
+    def __init__(self, host):
+        super(Message, self).__init__(
+            host, "message", use_profile=False, help=_("messages handling")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_param.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . import base
+from libervia.backend.core.i18n import _
+from .constants import Const as C
+
+__commands__ = ["Param"]
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        super(Get, self).__init__(
+            host, "get", need_connect=False, help=_("get a parameter value")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "category", nargs="?", help=_("category of the parameter")
+        )
+        self.parser.add_argument("name", nargs="?", help=_("name of the parameter"))
+        self.parser.add_argument(
+            "-a",
+            "--attribute",
+            type=str,
+            default="value",
+            help=_("name of the attribute to get"),
+        )
+        self.parser.add_argument(
+            "--security-limit", type=int, default=-1, help=_("security limit")
+        )
+
+    async def start(self):
+        if self.args.category is None:
+            categories = await self.host.bridge.params_categories_get()
+            print("\n".join(categories))
+        elif self.args.name is None:
+            try:
+                values_dict = await self.host.bridge.params_values_from_category_get_async(
+                    self.args.category, self.args.security_limit, "", "", self.profile
+                )
+            except Exception as e:
+                self.disp(
+                    _("can't find requested parameters: {e}").format(e=e), error=True
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                for name, value in values_dict.items():
+                    print(f"{name}\t{value}")
+        else:
+            try:
+                value = await self.host.bridge.param_get_a_async(
+                    self.args.name,
+                    self.args.category,
+                    self.args.attribute,
+                    self.args.security_limit,
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(
+                    _("can't find requested parameter: {e}").format(e=e), error=True
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                print(value)
+        self.host.quit()
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        super(Set, self).__init__(
+            host, "set", need_connect=False, help=_("set a parameter value")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("category", help=_("category of the parameter"))
+        self.parser.add_argument("name", help=_("name of the parameter"))
+        self.parser.add_argument("value", help=_("name of the parameter"))
+        self.parser.add_argument(
+            "--security-limit", type=int, default=-1, help=_("security limit")
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.param_set(
+                self.args.name,
+                self.args.value,
+                self.args.category,
+                self.args.security_limit,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't set requested parameter: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class SaveTemplate(base.CommandBase):
+    # FIXME: this should probably be removed, it's not used and not useful for end-user
+
+    def __init__(self, host):
+        super(SaveTemplate, self).__init__(
+            host,
+            "save",
+            use_profile=False,
+            help=_("save parameters template to xml file"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("filename", type=str, help=_("output file"))
+
+    async def start(self):
+        """Save parameters template to XML file"""
+        try:
+            await self.host.bridge.params_template_save(self.args.filename)
+        except Exception as e:
+            self.disp(_("can't save parameters to file: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                _("parameters saved to file {filename}").format(
+                    filename=self.args.filename
+                )
+            )
+            self.host.quit()
+
+
+class LoadTemplate(base.CommandBase):
+    # FIXME: this should probably be removed, it's not used and not useful for end-user
+
+    def __init__(self, host):
+        super(LoadTemplate, self).__init__(
+            host,
+            "load",
+            use_profile=False,
+            help=_("load parameters template from xml file"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("filename", type=str, help=_("input file"))
+
+    async def start(self):
+        """Load parameters template from xml file"""
+        try:
+            self.host.bridge.params_template_load(self.args.filename)
+        except Exception as e:
+            self.disp(_("can't load parameters from file: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                _("parameters loaded from file {filename}").format(
+                    filename=self.args.filename
+                )
+            )
+            self.host.quit()
+
+
+class Param(base.CommandBase):
+    subcommands = (Get, Set, SaveTemplate, LoadTemplate)
+
+    def __init__(self, host):
+        super(Param, self).__init__(
+            host, "param", use_profile=False, help=_("Save/load parameters template")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_ping.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+
+__commands__ = ["Ping"]
+
+
+class Ping(base.CommandBase):
+    def __init__(self, host):
+        super(Ping, self).__init__(host, "ping", help=_("ping XMPP entity"))
+
+    def add_parser_options(self):
+        self.parser.add_argument("jid", help=_("jid to ping"))
+        self.parser.add_argument(
+            "-d", "--delay-only", action="store_true", help=_("output delay only (in s)")
+        )
+
+    async def start(self):
+        try:
+            pong_time = await self.host.bridge.ping(self.args.jid, self.profile)
+        except Exception as e:
+            self.disp(msg=_("can't do the ping: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            msg = pong_time if self.args.delay_only else f"PONG ({pong_time} s)"
+            self.disp(msg)
+            self.host.quit()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_pipe.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+import asyncio
+import errno
+from functools import partial
+import socket
+import sys
+
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.frontends.jp import base
+from libervia.frontends.jp import xmlui_manager
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.tools import jid
+
+__commands__ = ["Pipe"]
+
+START_PORT = 9999
+
+
+class PipeOut(base.CommandBase):
+    def __init__(self, host):
+        super(PipeOut, self).__init__(host, "out", help=_("send a pipe a stream"))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jid", help=_("the destination jid")
+        )
+
+    async def start(self):
+        """ Create named pipe, and send stdin to it """
+        try:
+            port = await self.host.bridge.stream_out(
+                await self.host.get_full_jid(self.args.jid),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't start stream: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            # FIXME: we use temporarily blocking code here, as it simplify
+            #        asyncio port: "loop.connect_read_pipe(lambda: reader_protocol,
+            #        sys.stdin.buffer)" doesn't work properly when a file is piped in
+            #        (we get a "ValueError: Pipe transport is for pipes/sockets only.")
+            #        while it's working well for simple text sending.
+
+            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            s.connect(("127.0.0.1", int(port)))
+
+            while True:
+                buf = sys.stdin.buffer.read(4096)
+                if not buf:
+                    break
+                try:
+                    s.sendall(buf)
+                except socket.error as e:
+                    if e.errno == errno.EPIPE:
+                        sys.stderr.write(f"e\n")
+                        self.host.quit(1)
+                    else:
+                        raise e
+            self.host.quit()
+
+
+async def handle_stream_in(reader, writer, host):
+    """Write all received data to stdout"""
+    while True:
+        data = await reader.read(4096)
+        if not data:
+            break
+        sys.stdout.buffer.write(data)
+        try:
+            sys.stdout.flush()
+        except IOError as e:
+            sys.stderr.write(f"{e}\n")
+            break
+    host.quit_from_signal()
+
+
+class PipeIn(base.CommandAnswering):
+    def __init__(self, host):
+        super(PipeIn, self).__init__(host, "in", help=_("receive a pipe stream"))
+        self.action_callbacks = {"STREAM": self.on_stream_action}
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "jids",
+            nargs="*",
+            help=_('Jids accepted (none means "accept everything")'),
+        )
+
+    def get_xmlui_id(self, action_data):
+        try:
+            xml_ui = action_data["xmlui"]
+        except KeyError:
+            self.disp(_("Action has no XMLUI"), 1)
+        else:
+            ui = xmlui_manager.create(self.host, xml_ui)
+            if not ui.submit_id:
+                self.disp(_("Invalid XMLUI received"), error=True)
+                self.quit_from_signal(C.EXIT_INTERNAL_ERROR)
+            return ui.submit_id
+
+    async def on_stream_action(self, action_data, action_id, security_limit, profile):
+        xmlui_id = self.get_xmlui_id(action_data)
+        if xmlui_id is None:
+            self.host.quit_from_signal(C.EXIT_ERROR)
+        try:
+            from_jid = jid.JID(action_data["from_jid"])
+        except KeyError:
+            self.disp(_("Ignoring action without from_jid data"), error=True)
+            return
+
+        if not self.bare_jids or from_jid.bare in self.bare_jids:
+            host, port = "localhost", START_PORT
+            while True:
+                try:
+                    server = await asyncio.start_server(
+                        partial(handle_stream_in, host=self.host), host, port)
+                except socket.error as e:
+                    if e.errno == errno.EADDRINUSE:
+                        port += 1
+                    else:
+                        raise e
+                else:
+                    break
+            xmlui_data = {"answer": C.BOOL_TRUE, "port": str(port)}
+            await self.host.bridge.action_launch(
+                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
+            )
+            async with server:
+                await server.serve_forever()
+            self.host.quit_from_signal()
+
+    async def start(self):
+        self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids]
+        await self.start_answering()
+
+
+class Pipe(base.CommandBase):
+    subcommands = (PipeOut, PipeIn)
+
+    def __init__(self, host):
+        super(Pipe, self).__init__(
+            host, "pipe", use_profile=False, help=_("stream piping through XMPP")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_profile.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,280 @@
+#!/usr/bin/env python3
+
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+"""This module permits to manage profiles. It can list, create, delete
+and retrieve information about a profile."""
+
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp import base
+
+log = getLogger(__name__)
+
+
+__commands__ = ["Profile"]
+
+PROFILE_HELP = _('The name of the profile')
+
+
+class ProfileConnect(base.CommandBase):
+    """Dummy command to use profile_session parent, i.e. to be able to connect without doing anything else"""
+
+    def __init__(self, host):
+        # it's weird to have a command named "connect" with need_connect=False, but it can be handy to be able
+        # to launch just the session, so some paradoxes don't hurt
+        super(ProfileConnect, self).__init__(host, 'connect', need_connect=False, help=('connect a profile'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        # connection is already managed by profile common commands
+        # so we just need to check arguments and quit
+        if not self.args.connect and not self.args.start_session:
+            self.parser.error(_("You need to use either --connect or --start-session"))
+        self.host.quit()
+
+class ProfileDisconnect(base.CommandBase):
+
+    def __init__(self, host):
+        super(ProfileDisconnect, self).__init__(host, 'disconnect', need_connect=False, help=('disconnect a profile'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            await self.host.bridge.disconnect(self.args.profile)
+        except Exception as e:
+            self.disp(f"can't disconnect profile: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class ProfileCreate(base.CommandBase):
+    def __init__(self, host):
+        super(ProfileCreate, self).__init__(
+            host, 'create', use_profile=False, help=('create a new profile'))
+
+    def add_parser_options(self):
+        self.parser.add_argument('profile', type=str, help=_('the name of the profile'))
+        self.parser.add_argument(
+            '-p', '--password', type=str, default='',
+            help=_('the password of the profile'))
+        self.parser.add_argument(
+            '-j', '--jid', type=str, help=_('the jid of the profile'))
+        self.parser.add_argument(
+            '-x', '--xmpp-password', type=str,
+            help=_(
+                'the password of the XMPP account (use profile password if not specified)'
+            ),
+            metavar='PASSWORD')
+        self.parser.add_argument(
+            '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?',
+            const=C.BOOL_TRUE,
+            help=_('connect this profile automatically when backend starts')
+        )
+        self.parser.add_argument(
+            '-C', '--component', default='',
+            help=_('set to component import name (entry point) if this is a component'))
+
+    async def start(self):
+        """Create a new profile"""
+        if self.args.profile in await self.host.bridge.profiles_list_get():
+            self.disp(f"Profile {self.args.profile} already exists.", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERROR)
+        try:
+            await self.host.bridge.profile_create(
+                self.args.profile, self.args.password, self.args.component)
+        except Exception as e:
+            self.disp(f"can't create profile: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        try:
+            await self.host.bridge.profile_start_session(
+                self.args.password, self.args.profile)
+        except Exception as e:
+            self.disp(f"can't start profile session: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if self.args.jid:
+            await self.host.bridge.param_set(
+                "JabberID", self.args.jid, "Connection", profile_key=self.args.profile)
+        xmpp_pwd = self.args.password or self.args.xmpp_password
+        if xmpp_pwd:
+            await self.host.bridge.param_set(
+                "Password", xmpp_pwd, "Connection", profile_key=self.args.profile)
+
+        if self.args.autoconnect is not None:
+            await self.host.bridge.param_set(
+                "autoconnect_backend", self.args.autoconnect, "Connection",
+                profile_key=self.args.profile)
+
+        self.disp(f'profile {self.args.profile} created successfully', 1)
+        self.host.quit()
+
+
+class ProfileDefault(base.CommandBase):
+    def __init__(self, host):
+        super(ProfileDefault, self).__init__(
+            host, 'default', use_profile=False, help=('print default profile'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        print(await self.host.bridge.profile_name_get('@DEFAULT@'))
+        self.host.quit()
+
+
+class ProfileDelete(base.CommandBase):
+    def __init__(self, host):
+        super(ProfileDelete, self).__init__(host, 'delete', use_profile=False, help=('delete a profile'))
+
+    def add_parser_options(self):
+        self.parser.add_argument('profile', type=str, help=PROFILE_HELP)
+        self.parser.add_argument('-f', '--force', action='store_true', help=_('delete profile without confirmation'))
+
+    async def start(self):
+        if self.args.profile not in await self.host.bridge.profiles_list_get():
+            log.error(f"Profile {self.args.profile} doesn't exist.")
+            self.host.quit(C.EXIT_NOT_FOUND)
+        if not self.args.force:
+            message = f"Are you sure to delete profile [{self.args.profile}] ?"
+            cancel_message = "Profile deletion cancelled"
+            await self.host.confirm_or_quit(message, cancel_message)
+
+        await self.host.bridge.profile_delete_async(self.args.profile)
+        self.host.quit()
+
+
+class ProfileInfo(base.CommandBase):
+
+    def __init__(self, host):
+        super(ProfileInfo, self).__init__(
+            host, 'info', need_connect=False, use_output=C.OUTPUT_DICT,
+            help=_('get information about a profile'))
+        self.to_show = [(_("jid"), "Connection", "JabberID"),]
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            '--show-password', action='store_true',
+            help=_('show the XMPP password IN CLEAR TEXT'))
+
+    async def start(self):
+        if self.args.show_password:
+            self.to_show.append((_("XMPP password"), "Connection", "Password"))
+        self.to_show.append((_("autoconnect (backend)"), "Connection",
+                                "autoconnect_backend"))
+        data = {}
+        for label, category, name in self.to_show:
+            try:
+                value = await self.host.bridge.param_get_a_async(
+                    name, category, profile_key=self.host.profile)
+            except Exception as e:
+                self.disp(f"can't get {name}/{category} param: {e}", error=True)
+            else:
+                data[label] = value
+
+        await self.output(data)
+        self.host.quit()
+
+
+class ProfileList(base.CommandBase):
+    def __init__(self, host):
+        super(ProfileList, self).__init__(
+            host, 'list', use_profile=False, use_output='list', help=('list profiles'))
+
+    def add_parser_options(self):
+        group = self.parser.add_mutually_exclusive_group()
+        group.add_argument(
+            '-c', '--clients', action='store_true', help=_('get clients profiles only'))
+        group.add_argument(
+            '-C', '--components', action='store_true',
+            help=('get components profiles only'))
+
+    async def start(self):
+        if self.args.clients:
+            clients, components = True, False
+        elif self.args.components:
+            clients, components = False, True
+        else:
+            clients, components = True, True
+        await self.output(await self.host.bridge.profiles_list_get(clients, components))
+        self.host.quit()
+
+
+class ProfileModify(base.CommandBase):
+
+    def __init__(self, host):
+        super(ProfileModify, self).__init__(
+            host, 'modify', need_connect=False, help=_('modify an existing profile'))
+
+    def add_parser_options(self):
+        profile_pwd_group = self.parser.add_mutually_exclusive_group()
+        profile_pwd_group.add_argument(
+            '-w', '--password', help=_('change the password of the profile'))
+        profile_pwd_group.add_argument(
+            '--disable-password', action='store_true',
+            help=_('disable profile password (dangerous!)'))
+        self.parser.add_argument('-j', '--jid', help=_('the jid of the profile'))
+        self.parser.add_argument(
+            '-x', '--xmpp-password', help=_('change the password of the XMPP account'),
+            metavar='PASSWORD')
+        self.parser.add_argument(
+            '-D', '--default', action='store_true', help=_('set as default profile'))
+        self.parser.add_argument(
+            '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?',
+            const=C.BOOL_TRUE,
+            help=_('connect this profile automatically when backend starts')
+        )
+
+    async def start(self):
+        if self.args.disable_password:
+            self.args.password = ''
+        if self.args.password is not None:
+            await self.host.bridge.param_set(
+                "Password", self.args.password, "General", profile_key=self.host.profile)
+        if self.args.jid is not None:
+            await self.host.bridge.param_set(
+                "JabberID", self.args.jid, "Connection", profile_key=self.host.profile)
+        if self.args.xmpp_password is not None:
+            await self.host.bridge.param_set(
+                "Password", self.args.xmpp_password, "Connection",
+                profile_key=self.host.profile)
+        if self.args.default:
+            await self.host.bridge.profile_set_default(self.host.profile)
+        if self.args.autoconnect is not None:
+            await self.host.bridge.param_set(
+                "autoconnect_backend", self.args.autoconnect, "Connection",
+                profile_key=self.host.profile)
+
+        self.host.quit()
+
+
+class Profile(base.CommandBase):
+    subcommands = (
+        ProfileConnect, ProfileDisconnect, ProfileCreate, ProfileDefault, ProfileDelete,
+        ProfileInfo, ProfileList, ProfileModify)
+
+    def __init__(self, host):
+        super(Profile, self).__init__(
+            host, 'profile', use_profile=False, help=_('profile commands'))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_pubsub.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,3030 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+
+import argparse
+import os.path
+import re
+import sys
+import subprocess
+import asyncio
+import json
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.jp import common
+from libervia.frontends.jp import arg_tools
+from libervia.frontends.jp import xml_tools
+from functools import partial
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import date_utils
+from libervia.frontends.tools import jid, strings
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+
+__commands__ = ["Pubsub"]
+
+PUBSUB_TMP_DIR = "pubsub"
+PUBSUB_SCHEMA_TMP_DIR = PUBSUB_TMP_DIR + "_schema"
+ALLOWED_SUBSCRIPTIONS_OWNER = ("subscribed", "pending", "none")
+
+# TODO: need to split this class in several modules, plugin should handle subcommands
+
+
+class NodeInfo(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "info",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("retrieve node configuration"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            action="append",
+            dest="keys",
+            help=_("data key to filter"),
+        )
+
+    def remove_prefix(self, key):
+        return key[7:] if key.startswith("pubsub#") else key
+
+    def filter_key(self, key):
+        return any((key == k or key == "pubsub#" + k) for k in self.args.keys)
+
+    async def start(self):
+        try:
+            config_dict = await self.host.bridge.ps_node_configuration_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found":
+                self.disp(
+                    f"The node {self.args.node} doesn't exist on {self.args.service}",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(f"can't get node configuration: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            key_filter = (lambda k: True) if not self.args.keys else self.filter_key
+            config_dict = {
+                self.remove_prefix(k): v for k, v in config_dict.items() if key_filter(k)
+            }
+            await self.output(config_dict)
+            self.host.quit()
+
+
+class NodeCreate(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "create",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("create a node"),
+        )
+
+    @staticmethod
+    def add_node_config_options(parser):
+        parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            default=[],
+            metavar=("KEY", "VALUE"),
+            help=_("configuration field to set"),
+        )
+        parser.add_argument(
+            "-F",
+            "--full-prefix",
+            action="store_true",
+            help=_('don\'t prepend "pubsub#" prefix to field names'),
+        )
+
+    def add_parser_options(self):
+        self.add_node_config_options(self.parser)
+
+    @staticmethod
+    def get_config_options(args):
+        if not args.full_prefix:
+            return {"pubsub#" + k: v for k, v in args.fields}
+        else:
+            return dict(args.fields)
+
+    async def start(self):
+        options = self.get_config_options(self.args)
+        try:
+            node_id = await self.host.bridge.ps_node_create(
+                self.args.service,
+                self.args.node,
+                options,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't create node: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if self.host.verbosity:
+                announce = _("node created successfully: ")
+            else:
+                announce = ""
+            self.disp(announce + node_id)
+            self.host.quit()
+
+
+class NodePurge(base.CommandBase):
+    def __init__(self, host):
+        super(NodePurge, self).__init__(
+            host,
+            "purge",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("purge a node (i.e. remove all items from it)"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("purge node without confirmation"),
+        )
+
+    async def start(self):
+        if not self.args.force:
+            if not self.args.service:
+                message = _(
+                    "Are you sure to purge PEP node [{node}]? This will "
+                    "delete ALL items from it!"
+                ).format(node=self.args.node)
+            else:
+                message = _(
+                    "Are you sure to delete node [{node}] on service "
+                    "[{service}]? This will delete ALL items from it!"
+                ).format(node=self.args.node, service=self.args.service)
+            await self.host.confirm_or_quit(message, _("node purge cancelled"))
+
+        try:
+            await self.host.bridge.ps_node_purge(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(msg=_("can't purge node: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("node [{node}] purged successfully").format(node=self.args.node))
+            self.host.quit()
+
+
+class NodeDelete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("delete a node"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=_("delete node without confirmation"),
+        )
+
+    async def start(self):
+        if not self.args.force:
+            if not self.args.service:
+                message = _("Are you sure to delete PEP node [{node}] ?").format(
+                    node=self.args.node
+                )
+            else:
+                message = _(
+                    "Are you sure to delete node [{node}] on " "service [{service}]?"
+                ).format(node=self.args.node, service=self.args.service)
+            await self.host.confirm_or_quit(message, _("node deletion cancelled"))
+
+        try:
+            await self.host.bridge.ps_node_delete(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't delete node: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("node [{node}] deleted successfully").format(node=self.args.node))
+            self.host.quit()
+
+
+class NodeSet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("set node configuration"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            required=True,
+            metavar=("KEY", "VALUE"),
+            help=_("configuration field to set (required)"),
+        )
+        self.parser.add_argument(
+            "-F",
+            "--full-prefix",
+            action="store_true",
+            help=_('don\'t prepend "pubsub#" prefix to field names'),
+        )
+
+    def get_key_name(self, k):
+        if self.args.full_prefix or k.startswith("pubsub#"):
+            return k
+        else:
+            return "pubsub#" + k
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_node_configuration_set(
+                self.args.service,
+                self.args.node,
+                {self.get_key_name(k): v for k, v in self.args.fields},
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set node configuration: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("node configuration successful"), 1)
+            self.host.quit()
+
+
+class NodeImport(base.CommandBase):
+    def __init__(self, host):
+        super(NodeImport, self).__init__(
+            host,
+            "import",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("import raw XML to a node"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--admin",
+            action="store_true",
+            help=_("do a pubsub admin request, needed to change publisher"),
+        )
+        self.parser.add_argument(
+            "import_file",
+            type=argparse.FileType(),
+            help=_(
+                "path to the XML file with data to import. The file must contain "
+                "whole XML of each item to import."
+            ),
+        )
+
+    async def start(self):
+        try:
+            element, etree = xml_tools.etree_parse(
+                self, self.args.import_file, reraise=True
+            )
+        except Exception as e:
+            from lxml.etree import XMLSyntaxError
+
+            if isinstance(e, XMLSyntaxError) and e.code == 5:
+                # we have extra content, this probaby means that item are not wrapped
+                # so we wrap them here and try again
+                self.args.import_file.seek(0)
+                xml_buf = "<import>" + self.args.import_file.read() + "</import>"
+                element, etree = xml_tools.etree_parse(self, xml_buf)
+
+                # we reverse element as we expect to have most recently published element first
+                # TODO: make this more explicit and add an option
+        element[:] = reversed(element)
+
+        if not all([i.tag == "{http://jabber.org/protocol/pubsub}item" for i in element]):
+            self.disp(
+                _("You are not using list of pubsub items, we can't import this file"),
+                error=True,
+            )
+            self.host.quit(C.EXIT_DATA_ERROR)
+            return
+
+        items = [etree.tostring(i, encoding="unicode") for i in element]
+        if self.args.admin:
+            method = self.host.bridge.ps_admin_items_send
+        else:
+            self.disp(
+                _(
+                    "Items are imported without using admin mode, publisher can't "
+                    "be changed"
+                )
+            )
+            method = self.host.bridge.ps_items_send
+
+        try:
+            items_ids = await method(
+                self.args.service,
+                self.args.node,
+                items,
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't send items: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if items_ids:
+                self.disp(
+                    _("items published with id(s) {items_ids}").format(
+                        items_ids=", ".join(items_ids)
+                    )
+                )
+            else:
+                self.disp(_("items published"))
+            self.host.quit()
+
+
+class NodeAffiliationsGet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("retrieve node affiliations (for node owner)"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            affiliations = await self.host.bridge.ps_node_affiliations_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get node affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(affiliations)
+            self.host.quit()
+
+
+class NodeAffiliationsSet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("set affiliations (for node owner)"),
+        )
+
+    def add_parser_options(self):
+        # XXX: we use optional argument syntax for a required one because list of list of 2 elements
+        #      (used to construct dicts) don't work with positional arguments
+        self.parser.add_argument(
+            "-a",
+            "--affiliation",
+            dest="affiliations",
+            metavar=("JID", "AFFILIATION"),
+            required=True,
+            action="append",
+            nargs=2,
+            help=_("entity/affiliation couple(s)"),
+        )
+
+    async def start(self):
+        affiliations = dict(self.args.affiliations)
+        try:
+            await self.host.bridge.ps_node_affiliations_set(
+                self.args.service,
+                self.args.node,
+                affiliations,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set node affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("affiliations have been set"), 1)
+            self.host.quit()
+
+
+class NodeAffiliations(base.CommandBase):
+    subcommands = (NodeAffiliationsGet, NodeAffiliationsSet)
+
+    def __init__(self, host):
+        super(NodeAffiliations, self).__init__(
+            host,
+            "affiliations",
+            use_profile=False,
+            help=_("set or retrieve node affiliations"),
+        )
+
+
+class NodeSubscriptionsGet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("retrieve node subscriptions (for node owner)"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--public",
+            action="store_true",
+            help=_("get public subscriptions"),
+        )
+
+    async def start(self):
+        if self.args.public:
+            method = self.host.bridge.ps_public_node_subscriptions_get
+        else:
+            method = self.host.bridge.ps_node_subscriptions_get
+        try:
+            subscriptions = await method(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get node subscriptions: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(subscriptions)
+            self.host.quit()
+
+
+class StoreSubscriptionAction(argparse.Action):
+    """Action which handle subscription parameter for owner
+
+    list is given by pairs: jid and subscription state
+    if subscription state is not specified, it default to "subscribed"
+    """
+
+    def __call__(self, parser, namespace, values, option_string):
+        dest_dict = getattr(namespace, self.dest)
+        while values:
+            jid_s = values.pop(0)
+            try:
+                subscription = values.pop(0)
+            except IndexError:
+                subscription = "subscribed"
+            if subscription not in ALLOWED_SUBSCRIPTIONS_OWNER:
+                parser.error(
+                    _("subscription must be one of {}").format(
+                        ", ".join(ALLOWED_SUBSCRIPTIONS_OWNER)
+                    )
+                )
+            dest_dict[jid_s] = subscription
+
+
+class NodeSubscriptionsSet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("set/modify subscriptions (for node owner)"),
+        )
+
+    def add_parser_options(self):
+        # XXX: we use optional argument syntax for a required one because list of list of 2 elements
+        #      (uses to construct dicts) don't work with positional arguments
+        self.parser.add_argument(
+            "-S",
+            "--subscription",
+            dest="subscriptions",
+            default={},
+            nargs="+",
+            metavar=("JID [SUSBSCRIPTION]"),
+            required=True,
+            action=StoreSubscriptionAction,
+            help=_("entity/subscription couple(s)"),
+        )
+
+    async def start(self):
+        try:
+            self.host.bridge.ps_node_subscriptions_set(
+                self.args.service,
+                self.args.node,
+                self.args.subscriptions,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set node subscriptions: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("subscriptions have been set"), 1)
+            self.host.quit()
+
+
+class NodeSubscriptions(base.CommandBase):
+    subcommands = (NodeSubscriptionsGet, NodeSubscriptionsSet)
+
+    def __init__(self, host):
+        super(NodeSubscriptions, self).__init__(
+            host,
+            "subscriptions",
+            use_profile=False,
+            help=_("get or modify node subscriptions"),
+        )
+
+
+class NodeSchemaSet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("set/replace a schema"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("schema", help=_("schema to set (must be XML)"))
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_schema_set(
+                self.args.service,
+                self.args.node,
+                self.args.schema,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set schema: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("schema has been set"), 1)
+            self.host.quit()
+
+
+class NodeSchemaEdit(base.CommandBase, common.BaseEdit):
+    use_items = False
+
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "edit",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_draft=True,
+            use_verbose=True,
+            help=_("edit a schema"),
+        )
+        common.BaseEdit.__init__(self, self.host, PUBSUB_SCHEMA_TMP_DIR)
+
+    def add_parser_options(self):
+        pass
+
+    async def publish(self, schema):
+        try:
+            await self.host.bridge.ps_schema_set(
+                self.args.service,
+                self.args.node,
+                schema,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't set schema: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("schema has been set"), 1)
+            self.host.quit()
+
+    async def ps_schema_get_cb(self, schema):
+        try:
+            from lxml import etree
+        except ImportError:
+            self.disp(
+                "lxml module must be installed to use edit, please install it "
+                'with "pip install lxml"',
+                error=True,
+            )
+            self.host.quit(1)
+        content_file_obj, content_file_path = self.get_tmp_file()
+        schema = schema.strip()
+        if schema:
+            parser = etree.XMLParser(remove_blank_text=True)
+            schema_elt = etree.fromstring(schema, parser)
+            content_file_obj.write(
+                etree.tostring(schema_elt, encoding="utf-8", pretty_print=True)
+            )
+            content_file_obj.seek(0)
+        await self.run_editor(
+            "pubsub_schema_editor_args", content_file_path, content_file_obj
+        )
+
+    async def start(self):
+        try:
+            schema = await self.host.bridge.ps_schema_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found" or e.classname == "NotFound":
+                schema = ""
+            else:
+                self.disp(f"can't edit schema: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        await self.ps_schema_get_cb(schema)
+
+
+class NodeSchemaGet(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_XML,
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("get schema"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            schema = await self.host.bridge.ps_schema_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found" or e.classname == "NotFound":
+                schema = None
+            else:
+                self.disp(f"can't get schema: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        if schema:
+            await self.output(schema)
+            self.host.quit()
+        else:
+            self.disp(_("no schema found"), 1)
+            self.host.quit(C.EXIT_NOT_FOUND)
+
+
+class NodeSchema(base.CommandBase):
+    subcommands = (NodeSchemaSet, NodeSchemaEdit, NodeSchemaGet)
+
+    def __init__(self, host):
+        super(NodeSchema, self).__init__(
+            host, "schema", use_profile=False, help=_("data schema manipulation")
+        )
+
+
+class Node(base.CommandBase):
+    subcommands = (
+        NodeInfo,
+        NodeCreate,
+        NodePurge,
+        NodeDelete,
+        NodeSet,
+        NodeImport,
+        NodeAffiliations,
+        NodeSubscriptions,
+        NodeSchema,
+    )
+
+    def __init__(self, host):
+        super(Node, self).__init__(
+            host, "node", use_profile=False, help=_("node handling")
+        )
+
+
+class CacheGet(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "get",
+            use_output=C.OUTPUT_LIST_XML,
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.MULTI_ITEMS, C.CACHE},
+            help=_("get pubsub item(s) from cache"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-S",
+            "--sub-id",
+            default="",
+            help=_("subscription id"),
+        )
+
+    async def start(self):
+        try:
+            ps_result = data_format.deserialise(
+                await self.host.bridge.ps_cache_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    self.args.sub_id,
+                    self.get_pubsub_extra(),
+                    self.profile,
+                )
+            )
+        except BridgeException as e:
+            if e.classname == "NotFound":
+                self.disp(
+                    f"The node {self.args.node} from {self.args.service} is not in cache "
+                    f"for {self.profile}",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(f"can't get pubsub items from cache: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            await self.output(ps_result["items"])
+            self.host.quit(C.EXIT_OK)
+
+
+class CacheSync(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "sync",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("(re)synchronise a pubsub node"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_cache_sync(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found" or e.classname == "NotFound":
+                self.disp(
+                    f"The node {self.args.node} doesn't exist on {self.args.service}",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(f"can't synchronise pubsub node: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class CachePurge(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "purge",
+            use_profile=False,
+            help=_("purge (delete) items from cache"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-s", "--service", action="append", metavar="JID", dest="services",
+            help="purge items only for these services. If not specified, items from ALL "
+            "services will be purged. May be used several times."
+        )
+        self.parser.add_argument(
+            "-n", "--node", action="append", dest="nodes",
+            help="purge items only for these nodes. If not specified, items from ALL "
+            "nodes will be purged. May be used several times."
+        )
+        self.parser.add_argument(
+            "-p", "--profile", action="append", dest="profiles",
+            help="purge items only for these profiles. If not specified, items from ALL "
+            "profiles will be purged. May be used several times."
+        )
+        self.parser.add_argument(
+            "-b", "--updated-before", type=base.date_decoder, metavar="TIME_PATTERN",
+            help="purge items which have been last updated before given time."
+        )
+        self.parser.add_argument(
+            "-C", "--created-before", type=base.date_decoder, metavar="TIME_PATTERN",
+            help="purge items which have been last created before given time."
+        )
+        self.parser.add_argument(
+            "-t", "--type", action="append", dest="types",
+            help="purge items flagged with TYPE. May be used several times."
+        )
+        self.parser.add_argument(
+            "-S", "--subtype", action="append", dest="subtypes",
+            help="purge items flagged with SUBTYPE. May be used several times."
+        )
+        self.parser.add_argument(
+            "-f", "--force", action="store_true",
+            help=_("purge items without confirmation")
+        )
+
+    async def start(self):
+        if not self.args.force:
+            await self.host.confirm_or_quit(
+                _(
+                    "Are you sure to purge items from cache? You'll have to bypass cache "
+                    "or resynchronise nodes to access deleted items again."
+                ),
+                _("Items purgins has been cancelled.")
+            )
+        purge_data = {}
+        for key in (
+                "services", "nodes", "profiles", "updated_before", "created_before",
+                "types", "subtypes"
+        ):
+            value = getattr(self.args, key)
+            if value is not None:
+                purge_data[key] = value
+        try:
+            await self.host.bridge.ps_cache_purge(
+                data_format.serialise(
+                    purge_data
+                )
+            )
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class CacheReset(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "reset",
+            use_profile=False,
+            help=_("remove everything from cache"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--force", action="store_true",
+            help=_("reset cache without confirmation")
+        )
+
+    async def start(self):
+        if not self.args.force:
+            await self.host.confirm_or_quit(
+                _(
+                    "Are you sure to reset cache? All nodes and items will be removed "
+                    "from it, then it will be progressively refilled as if it were new. "
+                    "This may be resources intensive."
+                ),
+                _("Pubsub cache reset has been cancelled.")
+            )
+        try:
+            await self.host.bridge.ps_cache_reset()
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            self.host.quit(C.EXIT_OK)
+
+
+class CacheSearch(base.CommandBase):
+    def __init__(self, host):
+        extra_outputs = {
+            "default": self.default_output,
+            "xml": self.xml_output,
+            "xml-raw": self.xml_raw_output,
+        }
+        super().__init__(
+            host,
+            "search",
+            use_profile=False,
+            use_output=C.OUTPUT_LIST_DICT,
+            extra_outputs=extra_outputs,
+            help=_("search for pubsub items in cache"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--fts", help=_("Full-Text Search query"), metavar="FTS_QUERY"
+        )
+        self.parser.add_argument(
+            "-p", "--profile", action="append", dest="profiles", metavar="PROFILE",
+            help="search items only from these profiles. May be used several times."
+        )
+        self.parser.add_argument(
+            "-s", "--service", action="append", dest="services", metavar="SERVICE",
+            help="items must be from specified service. May be used several times."
+        )
+        self.parser.add_argument(
+            "-n", "--node", action="append", dest="nodes", metavar="NODE",
+            help="items must be in the specified node. May be used several times."
+        )
+        self.parser.add_argument(
+            "-t", "--type", action="append", dest="types", metavar="TYPE",
+            help="items must be of specified type. May be used several times."
+        )
+        self.parser.add_argument(
+            "-S", "--subtype", action="append", dest="subtypes", metavar="SUBTYPE",
+            help="items must be of specified subtype. May be used several times."
+        )
+        self.parser.add_argument(
+            "-P", "--payload", action="store_true", help=_("include item XML payload")
+        )
+        self.parser.add_argument(
+            "-o", "--order-by", action="append", nargs="+",
+            metavar=("ORDER", "[FIELD] [DIRECTION]"),
+            help=_("how items must be ordered. May be used several times.")
+        )
+        self.parser.add_argument(
+            "-l", "--limit", type=int, help=_("maximum number of items to return")
+        )
+        self.parser.add_argument(
+            "-i", "--index", type=int, help=_("return results starting from this index")
+        )
+        self.parser.add_argument(
+            "-F",
+            "--field",
+            action="append",
+            nargs=3,
+            dest="fields",
+            default=[],
+            metavar=("PATH", "OPERATOR", "VALUE"),
+            help=_("parsed data field filter. May be used several times."),
+        )
+        self.parser.add_argument(
+            "-k",
+            "--key",
+            action="append",
+            dest="keys",
+            metavar="KEY",
+            help=_(
+                "data key(s) to display. May be used several times. DEFAULT: show all "
+                "keys"
+            ),
+        )
+
+    async def start(self):
+        query = {}
+        for arg in ("fts", "profiles", "services", "nodes", "types", "subtypes"):
+            value = getattr(self.args, arg)
+            if value:
+                if arg in ("types", "subtypes"):
+                    # empty string is used to find items without type and/or subtype
+                    value = [v or None for v in value]
+                query[arg] = value
+        for arg in ("limit", "index"):
+            value = getattr(self.args, arg)
+            if value is not None:
+                query[arg] = value
+        if self.args.order_by is not None:
+            for order_data in self.args.order_by:
+                order, *args = order_data
+                if order == "field":
+                    if not args:
+                        self.parser.error(_("field data must be specified in --order-by"))
+                    elif len(args) == 1:
+                        path = args[0]
+                        direction = "asc"
+                    elif len(args) == 2:
+                        path, direction = args
+                    else:
+                        self.parser.error(_(
+                            "You can't specify more that 2 arguments for a field in "
+                            "--order-by"
+                        ))
+                    try:
+                        path = json.loads(path)
+                    except json.JSONDecodeError:
+                        pass
+                    order_query = {
+                        "path": path,
+                    }
+                else:
+                    order_query = {
+                        "order": order
+                    }
+                    if not args:
+                        direction = "asc"
+                    elif len(args) == 1:
+                        direction = args[0]
+                    else:
+                        self.parser.error(_(
+                            "there are too many arguments in --order-by option"
+                        ))
+                if direction.lower() not in ("asc", "desc"):
+                    self.parser.error(_("invalid --order-by direction: {direction!r}"))
+                order_query["direction"] = direction
+                query.setdefault("order-by", []).append(order_query)
+
+        if self.args.fields:
+            parsed = []
+            for field in self.args.fields:
+                path, operator, value = field
+                try:
+                    path = json.loads(path)
+                except json.JSONDecodeError:
+                    # this is not a JSON encoded value, we keep it as a string
+                    pass
+
+                if not isinstance(path, list):
+                    path = [path]
+
+                # handling of TP(<time pattern>)
+                if operator in (">", "gt", "<", "le", "between"):
+                    def datetime_sub(match):
+                        return str(date_utils.date_parse_ext(
+                            match.group(1), default_tz=date_utils.TZ_LOCAL
+                        ))
+                    value = re.sub(r"\bTP\(([^)]+)\)", datetime_sub, value)
+
+                try:
+                    value = json.loads(value)
+                except json.JSONDecodeError:
+                    # not JSON, as above we keep it as string
+                    pass
+
+                if operator in ("overlap", "ioverlap", "disjoint", "idisjoint"):
+                    if not isinstance(value, list):
+                        value = [value]
+
+                parsed.append({
+                    "path": path,
+                    "op": operator,
+                    "value": value
+                })
+
+            query["parsed"] = parsed
+
+        if self.args.payload or "xml" in self.args.output:
+            query["with_payload"] = True
+            if self.args.keys:
+                self.args.keys.append("item_payload")
+        try:
+            found_items = data_format.deserialise(
+                await self.host.bridge.ps_cache_search(
+                    data_format.serialise(query)
+                ),
+                type_check=list,
+            )
+        except BridgeException as e:
+            self.disp(f"can't search for pubsub items in cache: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            if self.args.keys:
+                found_items = [
+                    {k: v for k,v in item.items() if k in self.args.keys}
+                    for item in found_items
+                ]
+            await self.output(found_items)
+            self.host.quit(C.EXIT_OK)
+
+    def default_output(self, found_items):
+        for item in found_items:
+            for field in ("created", "published", "updated"):
+                try:
+                    timestamp = item[field]
+                except KeyError:
+                    pass
+                else:
+                    try:
+                        item[field] = common.format_time(timestamp)
+                    except ValueError:
+                        pass
+        self.host._outputs[C.OUTPUT_LIST_DICT]["simple"]["callback"](found_items)
+
+    def xml_output(self, found_items):
+        """Output prettified item payload"""
+        cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML]["callback"]
+        for item in found_items:
+            cb(item["item_payload"])
+
+    def xml_raw_output(self, found_items):
+        """Output item payload without prettifying"""
+        cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML_RAW]["callback"]
+        for item in found_items:
+            cb(item["item_payload"])
+
+
+class Cache(base.CommandBase):
+    subcommands = (
+        CacheGet,
+        CacheSync,
+        CachePurge,
+        CacheReset,
+        CacheSearch,
+    )
+
+    def __init__(self, host):
+        super(Cache, self).__init__(
+            host, "cache", use_profile=False, help=_("pubsub cache handling")
+        )
+
+
+class Set(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "set",
+            use_pubsub=True,
+            use_quiet=True,
+            pubsub_flags={C.NODE},
+            help=_("publish a new item or update an existing one"),
+        )
+
+    def add_parser_options(self):
+        NodeCreate.add_node_config_options(self.parser)
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("end-to-end encrypt the blog item")
+        )
+        self.parser.add_argument(
+            "--encrypt-for",
+            metavar="JID",
+            action="append",
+            help=_("encrypt a single item for")
+        )
+        self.parser.add_argument(
+            "-X",
+            "--sign",
+            action="store_true",
+            help=_("cryptographically sign the blog post")
+        )
+        self.parser.add_argument(
+            "item",
+            nargs="?",
+            default="",
+            help=_("id, URL of the item to update, keyword, or nothing for new item"),
+        )
+
+    async def start(self):
+        element, etree = xml_tools.etree_parse(self, sys.stdin)
+        element = xml_tools.get_payload(self, element)
+        payload = etree.tostring(element, encoding="unicode")
+        extra = {}
+        if self.args.encrypt:
+            extra["encrypted"] = True
+        if self.args.encrypt_for:
+            extra["encrypted_for"] = {"targets": self.args.encrypt_for}
+        if self.args.sign:
+            extra["signed"] = True
+        publish_options = NodeCreate.get_config_options(self.args)
+        if publish_options:
+            extra["publish_options"] = publish_options
+
+        try:
+            published_id = await self.host.bridge.ps_item_send(
+                self.args.service,
+                self.args.node,
+                payload,
+                self.args.item,
+                data_format.serialise(extra),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't send item: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if published_id:
+                if self.args.quiet:
+                    self.disp(published_id, end="")
+                else:
+                    self.disp(f"Item published at {published_id}")
+            else:
+                self.disp("Item published")
+            self.host.quit(C.EXIT_OK)
+
+
+class Get(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "get",
+            use_output=C.OUTPUT_LIST_XML,
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.MULTI_ITEMS, C.CACHE},
+            help=_("get pubsub item(s)"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-S",
+            "--sub-id",
+            default="",
+            help=_("subscription id"),
+        )
+        self.parser.add_argument(
+            "--no-decrypt",
+            action="store_true",
+            help=_("don't do automatic decryption of e2ee items"),
+        )
+        #  TODO: a key(s) argument to select keys to display
+
+    async def start(self):
+        extra = {}
+        if self.args.no_decrypt:
+            extra["decrypt"] = False
+        try:
+            ps_result = data_format.deserialise(
+                await self.host.bridge.ps_items_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    self.args.sub_id,
+                    self.get_pubsub_extra(extra),
+                    self.profile,
+                )
+            )
+        except BridgeException as e:
+            if e.condition == "item-not-found" or e.classname == "NotFound":
+                self.disp(
+                    f"The node {self.args.node} doesn't exist on {self.args.service}",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+            else:
+                self.disp(f"can't get pubsub items: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        except Exception as e:
+            self.disp(f"Internal error: {e}", error=True)
+            self.host.quit(C.EXIT_INTERNAL_ERROR)
+        else:
+            await self.output(ps_result["items"])
+            self.host.quit(C.EXIT_OK)
+
+
+class Delete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.ITEM, C.SINGLE_ITEM},
+            help=_("delete an item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--force", action="store_true", help=_("delete without confirmation")
+        )
+        self.parser.add_argument(
+            "--no-notification", dest="notify", action="store_false",
+            help=_("do not send notification (not recommended)")
+        )
+
+    async def start(self):
+        if not self.args.item:
+            self.parser.error(_("You need to specify an item to delete"))
+        if not self.args.force:
+            message = _("Are you sure to delete item {item_id} ?").format(
+                item_id=self.args.item
+            )
+            await self.host.confirm_or_quit(message, _("item deletion cancelled"))
+        try:
+            await self.host.bridge.ps_item_retract(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.notify,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't delete item: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("item {item} has been deleted").format(item=self.args.item))
+            self.host.quit(C.EXIT_OK)
+
+
+class Edit(base.CommandBase, common.BaseEdit):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "edit",
+            use_verbose=True,
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            use_draft=True,
+            help=_("edit an existing or new pubsub item"),
+        )
+        common.BaseEdit.__init__(self, self.host, PUBSUB_TMP_DIR)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-e",
+            "--encrypt",
+            action="store_true",
+            help=_("end-to-end encrypt the blog item")
+        )
+        self.parser.add_argument(
+            "--encrypt-for",
+            metavar="JID",
+            action="append",
+            help=_("encrypt a single item for")
+        )
+        self.parser.add_argument(
+            "-X",
+            "--sign",
+            action="store_true",
+            help=_("cryptographically sign the blog post")
+        )
+
+    async def publish(self, content):
+        extra = {}
+        if self.args.encrypt:
+            extra["encrypted"] = True
+        if self.args.encrypt_for:
+            extra["encrypted_for"] = {"targets": self.args.encrypt_for}
+        if self.args.sign:
+            extra["signed"] = True
+        published_id = await self.host.bridge.ps_item_send(
+            self.pubsub_service,
+            self.pubsub_node,
+            content,
+            self.pubsub_item or "",
+            data_format.serialise(extra),
+            self.profile,
+        )
+        if published_id:
+            self.disp("Item published at {pub_id}".format(pub_id=published_id))
+        else:
+            self.disp("Item published")
+
+    async def get_item_data(self, service, node, item):
+        try:
+            from lxml import etree
+        except ImportError:
+            self.disp(
+                "lxml module must be installed to use edit, please install it "
+                'with "pip install lxml"',
+                error=True,
+            )
+            self.host.quit(1)
+        items = [item] if item else []
+        ps_result = data_format.deserialise(
+            await self.host.bridge.ps_items_get(
+                service, node, 1, items, "", data_format.serialise({}), self.profile
+            )
+        )
+        item_raw = ps_result["items"][0]
+        parser = etree.XMLParser(remove_blank_text=True, recover=True)
+        item_elt = etree.fromstring(item_raw, parser)
+        item_id = item_elt.get("id")
+        try:
+            payload = item_elt[0]
+        except IndexError:
+            self.disp(_("Item has not payload"), 1)
+            return "", item_id
+        return etree.tostring(payload, encoding="unicode", pretty_print=True), item_id
+
+    async def start(self):
+        (
+            self.pubsub_service,
+            self.pubsub_node,
+            self.pubsub_item,
+            content_file_path,
+            content_file_obj,
+        ) = await self.get_item_path()
+        await self.run_editor("pubsub_editor_args", content_file_path, content_file_obj)
+        self.host.quit()
+
+
+class Rename(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "rename",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("rename a pubsub item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("new_id", help=_("new item id to use"))
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_item_rename(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.new_id,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't rename item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("Item renamed")
+            self.host.quit(C.EXIT_OK)
+
+
+class Subscribe(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "subscribe",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("subscribe to a node"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--public",
+            action="store_true",
+            help=_("make the registration visible for everybody"),
+        )
+
+    async def start(self):
+        options = {}
+        if self.args.public:
+            namespaces = await self.host.bridge.namespaces_get()
+            try:
+                ns_pps = namespaces["pps"]
+            except KeyError:
+                self.disp(
+                    "Pubsub Public Subscription plugin is not loaded, can't use --public "
+                    "option, subscription stopped", error=True
+                )
+                self.host.quit(C.EXIT_MISSING_FEATURE)
+            else:
+                options[f"{{{ns_pps}}}public"] = True
+        try:
+            sub_id = await self.host.bridge.ps_subscribe(
+                self.args.service,
+                self.args.node,
+                data_format.serialise(options),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't subscribe to node: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("subscription done"), 1)
+            if sub_id:
+                self.disp(_("subscription id: {sub_id}").format(sub_id=sub_id))
+            self.host.quit()
+
+
+class Unsubscribe(base.CommandBase):
+    # FIXME: check why we get a a NodeNotFound on subscribe just after unsubscribe
+
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "unsubscribe",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            use_verbose=True,
+            help=_("unsubscribe from a node"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_unsubscribe(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(_("can't unsubscribe from node: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("subscription removed"), 1)
+            self.host.quit()
+
+
+class Subscriptions(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "subscriptions",
+            use_output=C.OUTPUT_LIST_DICT,
+            use_pubsub=True,
+            help=_("retrieve all subscriptions on a service"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--public",
+            action="store_true",
+            help=_("get public subscriptions"),
+        )
+
+    async def start(self):
+        if self.args.public:
+            method = self.host.bridge.ps_public_subscriptions_get
+        else:
+            method = self.host.bridge.ps_subscriptions_get
+        try:
+            subscriptions = data_format.deserialise(
+                await method(
+                    self.args.service,
+                    self.args.node,
+                    self.profile,
+                ),
+                type_check=list
+            )
+        except Exception as e:
+            self.disp(_("can't retrieve subscriptions: {e}").format(e=e), error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(subscriptions)
+            self.host.quit()
+
+
+class Affiliations(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "affiliations",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            help=_("retrieve all affiliations on a service"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            affiliations = await self.host.bridge.ps_affiliations_get(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get node affiliations: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(affiliations)
+            self.host.quit()
+
+
+class Reference(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "reference",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("send a reference/mention to pubsub item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            default="mention",
+            choices=("data", "mention"),
+            help=_("type of reference to send (DEFAULT: mention)"),
+        )
+        self.parser.add_argument(
+            "recipient",
+            help=_("recipient of the reference")
+        )
+
+    async def start(self):
+        service = self.args.service or await self.host.get_profile_jid()
+        if self.args.item:
+            anchor = uri.build_xmpp_uri(
+                "pubsub", path=service, node=self.args.node, item=self.args.item
+            )
+        else:
+            anchor = uri.build_xmpp_uri("pubsub", path=service, node=self.args.node)
+
+        try:
+            await self.host.bridge.reference_send(
+                self.args.recipient,
+                anchor,
+                self.args.type,
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't send reference: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class Search(base.CommandBase):
+    """This command do a search without using MAM
+
+    This commands checks every items it finds by itself,
+    so it may be heavy in resources both for server and client
+    """
+
+    RE_FLAGS = re.MULTILINE | re.UNICODE
+    EXEC_ACTIONS = ("exec", "external")
+
+    def __init__(self, host):
+        # FIXME: C.NO_MAX is not needed here, and this can be globally removed from consts
+        #        the only interest is to change the help string, but this can be explained
+        #        extensively in man pages (max is for each node found)
+        base.CommandBase.__init__(
+            self,
+            host,
+            "search",
+            use_output=C.OUTPUT_XML,
+            use_pubsub=True,
+            pubsub_flags={C.MULTI_ITEMS, C.NO_MAX},
+            use_verbose=True,
+            help=_("search items corresponding to filters"),
+        )
+
+    @property
+    def etree(self):
+        """load lxml.etree only if needed"""
+        if self._etree is None:
+            from lxml import etree
+
+            self._etree = etree
+        return self._etree
+
+    def filter_opt(self, value, type_):
+        return (type_, value)
+
+    def filter_flag(self, value, type_):
+        value = C.bool(value)
+        return (type_, value)
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-D",
+            "--max-depth",
+            type=int,
+            default=0,
+            help=_(
+                "maximum depth of recursion (will search linked nodes if > 0, "
+                "DEFAULT: 0)"
+            ),
+        )
+        self.parser.add_argument(
+            "-M",
+            "--node-max",
+            type=int,
+            default=30,
+            help=_(
+                "maximum number of items to get per node ({} to get all items, "
+                "DEFAULT: 30)".format(C.NO_LIMIT)
+            ),
+        )
+        self.parser.add_argument(
+            "-N",
+            "--namespace",
+            action="append",
+            nargs=2,
+            default=[],
+            metavar="NAME NAMESPACE",
+            help=_("namespace to use for xpath"),
+        )
+
+        # filters
+        filter_text = partial(self.filter_opt, type_="text")
+        filter_re = partial(self.filter_opt, type_="regex")
+        filter_xpath = partial(self.filter_opt, type_="xpath")
+        filter_python = partial(self.filter_opt, type_="python")
+        filters = self.parser.add_argument_group(
+            _("filters"),
+            _("only items corresponding to following filters will be kept"),
+        )
+        filters.add_argument(
+            "-t",
+            "--text",
+            action="append",
+            dest="filters",
+            type=filter_text,
+            metavar="TEXT",
+            help=_("full text filter, item must contain this string (XML included)"),
+        )
+        filters.add_argument(
+            "-r",
+            "--regex",
+            action="append",
+            dest="filters",
+            type=filter_re,
+            metavar="EXPRESSION",
+            help=_("like --text but using a regular expression"),
+        )
+        filters.add_argument(
+            "-x",
+            "--xpath",
+            action="append",
+            dest="filters",
+            type=filter_xpath,
+            metavar="XPATH",
+            help=_("filter items which has elements matching this xpath"),
+        )
+        filters.add_argument(
+            "-P",
+            "--python",
+            action="append",
+            dest="filters",
+            type=filter_python,
+            metavar="PYTHON_CODE",
+            help=_(
+                "Python expression which much return a bool (True to keep item, "
+                'False to reject it). "item" is raw text item, "item_xml" is '
+                "lxml's etree.Element"
+            ),
+        )
+
+        # filters flags
+        flag_case = partial(self.filter_flag, type_="ignore-case")
+        flag_invert = partial(self.filter_flag, type_="invert")
+        flag_dotall = partial(self.filter_flag, type_="dotall")
+        flag_matching = partial(self.filter_flag, type_="only-matching")
+        flags = self.parser.add_argument_group(
+            _("filters flags"),
+            _("filters modifiers (change behaviour of following filters)"),
+        )
+        flags.add_argument(
+            "-C",
+            "--ignore-case",
+            action="append",
+            dest="filters",
+            type=flag_case,
+            const=("ignore-case", True),
+            nargs="?",
+            metavar="BOOLEAN",
+            help=_("(don't) ignore case in following filters (DEFAULT: case sensitive)"),
+        )
+        flags.add_argument(
+            "-I",
+            "--invert",
+            action="append",
+            dest="filters",
+            type=flag_invert,
+            const=("invert", True),
+            nargs="?",
+            metavar="BOOLEAN",
+            help=_("(don't) invert effect of following filters (DEFAULT: don't invert)"),
+        )
+        flags.add_argument(
+            "-A",
+            "--dot-all",
+            action="append",
+            dest="filters",
+            type=flag_dotall,
+            const=("dotall", True),
+            nargs="?",
+            metavar="BOOLEAN",
+            help=_("(don't) use DOTALL option for regex (DEFAULT: don't use)"),
+        )
+        flags.add_argument(
+            "-k",
+            "--only-matching",
+            action="append",
+            dest="filters",
+            type=flag_matching,
+            const=("only-matching", True),
+            nargs="?",
+            metavar="BOOLEAN",
+            help=_("keep only the matching part of the item"),
+        )
+
+        # action
+        self.parser.add_argument(
+            "action",
+            default="print",
+            nargs="?",
+            choices=("print", "exec", "external"),
+            help=_("action to do on found items (DEFAULT: print)"),
+        )
+        self.parser.add_argument("command", nargs=argparse.REMAINDER)
+
+    async def get_items(self, depth, service, node, items):
+        self.to_get += 1
+        try:
+            ps_result = data_format.deserialise(
+                await self.host.bridge.ps_items_get(
+                    service,
+                    node,
+                    self.args.node_max,
+                    items,
+                    "",
+                    self.get_pubsub_extra(),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(
+                f"can't get pubsub items at {service} (node: {node}): {e}",
+                error=True,
+            )
+            self.to_get -= 1
+        else:
+            await self.search(ps_result, depth)
+
+    def _check_pubsub_url(self, match, found_nodes):
+        """check that the matched URL is an xmpp: one
+
+        @param found_nodes(list[unicode]): found_nodes
+            this list will be filled while xmpp: URIs are discovered
+        """
+        url = match.group(0)
+        if url.startswith("xmpp"):
+            try:
+                url_data = uri.parse_xmpp_uri(url)
+            except ValueError:
+                return
+            if url_data["type"] == "pubsub":
+                found_node = {"service": url_data["path"], "node": url_data["node"]}
+                if "item" in url_data:
+                    found_node["item"] = url_data["item"]
+                found_nodes.append(found_node)
+
+    async def get_sub_nodes(self, item, depth):
+        """look for pubsub URIs in item, and get_items on the linked nodes"""
+        found_nodes = []
+        checkURI = partial(self._check_pubsub_url, found_nodes=found_nodes)
+        strings.RE_URL.sub(checkURI, item)
+        for data in found_nodes:
+            await self.get_items(
+                depth + 1,
+                data["service"],
+                data["node"],
+                [data["item"]] if "item" in data else [],
+            )
+
+    def parseXml(self, item):
+        try:
+            return self.etree.fromstring(item)
+        except self.etree.XMLSyntaxError:
+            self.disp(
+                _(
+                    "item doesn't looks like XML, you have probably used --only-matching "
+                    "somewhere before and we have no more XML"
+                ),
+                error=True,
+            )
+            self.host.quit(C.EXIT_BAD_ARG)
+
+    def filter(self, item):
+        """apply filters given on command line
+
+        if only-matching is used, item may be modified
+        @return (tuple[bool, unicode]): a tuple with:
+            - keep: True if item passed the filters
+            - item: it is returned in case of modifications
+        """
+        ignore_case = False
+        invert = False
+        dotall = False
+        only_matching = False
+        item_xml = None
+        for type_, value in self.args.filters:
+            keep = True
+
+            ## filters
+
+            if type_ == "text":
+                if ignore_case:
+                    if value.lower() not in item.lower():
+                        keep = False
+                else:
+                    if value not in item:
+                        keep = False
+                if keep and only_matching:
+                    # doesn't really make sens to keep a fixed string
+                    # so we raise an error
+                    self.host.disp(
+                        _("--only-matching used with fixed --text string, are you sure?"),
+                        error=True,
+                    )
+                    self.host.quit(C.EXIT_BAD_ARG)
+            elif type_ == "regex":
+                flags = self.RE_FLAGS
+                if ignore_case:
+                    flags |= re.IGNORECASE
+                if dotall:
+                    flags |= re.DOTALL
+                match = re.search(value, item, flags)
+                keep = match != None
+                if keep and only_matching:
+                    item = match.group()
+                    item_xml = None
+            elif type_ == "xpath":
+                if item_xml is None:
+                    item_xml = self.parseXml(item)
+                try:
+                    elts = item_xml.xpath(value, namespaces=self.args.namespace)
+                except self.etree.XPathEvalError as e:
+                    self.disp(_("can't use xpath: {reason}").format(reason=e), error=True)
+                    self.host.quit(C.EXIT_BAD_ARG)
+                keep = bool(elts)
+                if keep and only_matching:
+                    item_xml = elts[0]
+                    try:
+                        item = self.etree.tostring(item_xml, encoding="unicode")
+                    except TypeError:
+                        # we have a string only, not an element
+                        item = str(item_xml)
+                        item_xml = None
+            elif type_ == "python":
+                if item_xml is None:
+                    item_xml = self.parseXml(item)
+                cmd_ns = {"etree": self.etree, "item": item, "item_xml": item_xml}
+                try:
+                    keep = eval(value, cmd_ns)
+                except SyntaxError as e:
+                    self.disp(str(e), error=True)
+                    self.host.quit(C.EXIT_BAD_ARG)
+
+                    ## flags
+
+            elif type_ == "ignore-case":
+                ignore_case = value
+            elif type_ == "invert":
+                invert = value
+                #  we need to continue, else loop would end here
+                continue
+            elif type_ == "dotall":
+                dotall = value
+            elif type_ == "only-matching":
+                only_matching = value
+            else:
+                raise exceptions.InternalError(
+                    _("unknown filter type {type}").format(type=type_)
+                )
+
+            if invert:
+                keep = not keep
+            if not keep:
+                return False, item
+
+        return True, item
+
+    async def do_item_action(self, item, metadata):
+        """called when item has been kepts and the action need to be done
+
+        @param item(unicode): accepted item
+        """
+        action = self.args.action
+        if action == "print" or self.host.verbosity > 0:
+            try:
+                await self.output(item)
+            except self.etree.XMLSyntaxError:
+                # item is not valid XML, but a string
+                # can happen when --only-matching is used
+                self.disp(item)
+        if action in self.EXEC_ACTIONS:
+            item_elt = self.parseXml(item)
+            if action == "exec":
+                use = {
+                    "service": metadata["service"],
+                    "node": metadata["node"],
+                    "item": item_elt.get("id"),
+                    "profile": self.profile,
+                }
+                # we need to send a copy of self.args.command
+                # else it would be modified
+                parser_args, use_args = arg_tools.get_use_args(
+                    self.host, self.args.command, use, verbose=self.host.verbosity > 1
+                )
+                cmd_args = sys.argv[0:1] + parser_args + use_args
+            else:
+                cmd_args = self.args.command
+
+            self.disp(
+                "COMMAND: {command}".format(
+                    command=" ".join([arg_tools.escape(a) for a in cmd_args])
+                ),
+                2,
+            )
+            if action == "exec":
+                p = await asyncio.create_subprocess_exec(*cmd_args)
+                ret = await p.wait()
+            else:
+                p = await asyncio.create_subprocess_exec(*cmd_args, stdin=subprocess.PIPE)
+                await p.communicate(item.encode(sys.getfilesystemencoding()))
+                ret = p.returncode
+            if ret != 0:
+                self.disp(
+                    A.color(
+                        C.A_FAILURE,
+                        _("executed command failed with exit code {ret}").format(ret=ret),
+                    )
+                )
+
+    async def search(self, ps_result, depth):
+        """callback of get_items
+
+        this method filters items, get sub nodes if needed,
+        do the requested action, and exit the command when everything is done
+        @param items_data(tuple): result of get_items
+        @param depth(int): current depth level
+            0 for first node, 1 for first children, and so on
+        """
+        for item in ps_result["items"]:
+            if depth < self.args.max_depth:
+                await self.get_sub_nodes(item, depth)
+            keep, item = self.filter(item)
+            if not keep:
+                continue
+            await self.do_item_action(item, ps_result)
+
+            #  we check if we got all get_items results
+        self.to_get -= 1
+        if self.to_get == 0:
+            # yes, we can quit
+            self.host.quit()
+        assert self.to_get > 0
+
+    async def start(self):
+        if self.args.command:
+            if self.args.action not in self.EXEC_ACTIONS:
+                self.parser.error(
+                    _("Command can only be used with {actions} actions").format(
+                        actions=", ".join(self.EXEC_ACTIONS)
+                    )
+                )
+        else:
+            if self.args.action in self.EXEC_ACTIONS:
+                self.parser.error(_("you need to specify a command to execute"))
+        if not self.args.node:
+            # TODO: handle get service affiliations when node is not set
+            self.parser.error(_("empty node is not handled yet"))
+            # to_get is increased on each get and decreased on each answer
+            # when it reach 0 again, the command is finished
+        self.to_get = 0
+        self._etree = None
+        if self.args.filters is None:
+            self.args.filters = []
+        self.args.namespace = dict(
+            self.args.namespace + [("pubsub", "http://jabber.org/protocol/pubsub")]
+        )
+        await self.get_items(0, self.args.service, self.args.node, self.args.items)
+
+
+class Transform(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "transform",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.MULTI_ITEMS},
+            help=_("modify items of a node using an external command/script"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--apply",
+            action="store_true",
+            help=_("apply transformation (DEFAULT: do a dry run)"),
+        )
+        self.parser.add_argument(
+            "--admin",
+            action="store_true",
+            help=_("do a pubsub admin request, needed to change publisher"),
+        )
+        self.parser.add_argument(
+            "-I",
+            "--ignore-errors",
+            action="store_true",
+            help=_(
+                "if command return a non zero exit code, ignore the item and continue"
+            ),
+        )
+        self.parser.add_argument(
+            "-A",
+            "--all",
+            action="store_true",
+            help=_("get all items by looping over all pages using RSM"),
+        )
+        self.parser.add_argument(
+            "command_path",
+            help=_(
+                "path to the command to use. Will be called repetitivly with an "
+                "item as input. Output (full item XML) will be used as new one. "
+                'Return "DELETE" string to delete the item, and "SKIP" to ignore it'
+            ),
+        )
+
+    async def ps_items_send_cb(self, item_ids, metadata):
+        if item_ids:
+            self.disp(
+                _("items published with ids {item_ids}").format(
+                    item_ids=", ".join(item_ids)
+                )
+            )
+        else:
+            self.disp(_("items published"))
+        if self.args.all:
+            return await self.handle_next_page(metadata)
+        else:
+            self.host.quit()
+
+    async def handle_next_page(self, metadata):
+        """Retrieve new page through RSM or quit if we're in the last page
+
+        use to handle --all option
+        @param metadata(dict): metadata as returned by ps_items_get
+        """
+        try:
+            last = metadata["rsm"]["last"]
+            index = int(metadata["rsm"]["index"])
+            count = int(metadata["rsm"]["count"])
+        except KeyError:
+            self.disp(
+                _("Can't retrieve all items, RSM metadata not available"), error=True
+            )
+            self.host.quit(C.EXIT_MISSING_FEATURE)
+        except ValueError as e:
+            self.disp(
+                _("Can't retrieve all items, bad RSM metadata: {msg}").format(msg=e),
+                error=True,
+            )
+            self.host.quit(C.EXIT_ERROR)
+
+        if index + self.args.rsm_max >= count:
+            self.disp(_("All items transformed"))
+            self.host.quit(0)
+
+        self.disp(
+            _("Retrieving next page ({page_idx}/{page_total})").format(
+                page_idx=int(index / self.args.rsm_max) + 1,
+                page_total=int(count / self.args.rsm_max),
+            )
+        )
+
+        extra = self.get_pubsub_extra()
+        extra["rsm_after"] = last
+        try:
+            ps_result = await data_format.deserialise(
+                self.host.bridge.ps_items_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.rsm_max,
+                    self.args.items,
+                    "",
+                    data_format.serialise(extra),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't retrieve items: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.ps_items_get_cb(ps_result)
+
+    async def ps_items_get_cb(self, ps_result):
+        encoding = "utf-8"
+        new_items = []
+
+        for item in ps_result["items"]:
+            if self.check_duplicates:
+                # this is used when we are not ordering by creation
+                # to avoid infinite loop
+                item_elt, __ = xml_tools.etree_parse(self, item)
+                item_id = item_elt.get("id")
+                if item_id in self.items_ids:
+                    self.disp(
+                        _(
+                            "Duplicate found on item {item_id}, we have probably handled "
+                            "all items."
+                        ).format(item_id=item_id)
+                    )
+                    self.host.quit()
+                self.items_ids.append(item_id)
+
+                # we launch the command to filter the item
+            try:
+                p = await asyncio.create_subprocess_exec(
+                    self.args.command_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE
+                )
+            except OSError as e:
+                exit_code = C.EXIT_CMD_NOT_FOUND if e.errno == 2 else C.EXIT_ERROR
+                self.disp(f"Can't execute the command: {e}", error=True)
+                self.host.quit(exit_code)
+            encoding = "utf-8"
+            cmd_std_out, cmd_std_err = await p.communicate(item.encode(encoding))
+            ret = p.returncode
+            if ret != 0:
+                self.disp(
+                    f"The command returned a non zero status while parsing the "
+                    f"following item:\n\n{item}",
+                    error=True,
+                )
+                if self.args.ignore_errors:
+                    continue
+                else:
+                    self.host.quit(C.EXIT_CMD_ERROR)
+            if cmd_std_err is not None:
+                cmd_std_err = cmd_std_err.decode(encoding, errors="ignore")
+                self.disp(cmd_std_err, error=True)
+            cmd_std_out = cmd_std_out.decode(encoding).strip()
+            if cmd_std_out == "DELETE":
+                item_elt, __ = xml_tools.etree_parse(self, item)
+                item_id = item_elt.get("id")
+                self.disp(_("Deleting item {item_id}").format(item_id=item_id))
+                if self.args.apply:
+                    try:
+                        await self.host.bridge.ps_item_retract(
+                            self.args.service,
+                            self.args.node,
+                            item_id,
+                            False,
+                            self.profile,
+                        )
+                    except Exception as e:
+                        self.disp(f"can't delete item {item_id}: {e}", error=True)
+                        self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+                continue
+            elif cmd_std_out == "SKIP":
+                item_elt, __ = xml_tools.etree_parse(self, item)
+                item_id = item_elt.get("id")
+                self.disp(_("Skipping item {item_id}").format(item_id=item_id))
+                continue
+            element, etree = xml_tools.etree_parse(self, cmd_std_out)
+
+            # at this point command has been run and we have a etree.Element object
+            if element.tag not in ("item", "{http://jabber.org/protocol/pubsub}item"):
+                self.disp(
+                    "your script must return a whole item, this is not:\n{xml}".format(
+                        xml=etree.tostring(element, encoding="unicode")
+                    ),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_DATA_ERROR)
+
+            if not self.args.apply:
+                # we have a dry run, we just display filtered items
+                serialised = etree.tostring(
+                    element, encoding="unicode", pretty_print=True
+                )
+                self.disp(serialised)
+            else:
+                new_items.append(etree.tostring(element, encoding="unicode"))
+
+        if not self.args.apply:
+            # on dry run we have nothing to wait for, we can quit
+            if self.args.all:
+                return await self.handle_next_page(ps_result)
+            self.host.quit()
+        else:
+            if self.args.admin:
+                bridge_method = self.host.bridge.ps_admin_items_send
+            else:
+                bridge_method = self.host.bridge.ps_items_send
+
+            try:
+                ps_items_send_result = await bridge_method(
+                    self.args.service,
+                    self.args.node,
+                    new_items,
+                    "",
+                    self.profile,
+                )
+            except Exception as e:
+                self.disp(f"can't send item: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                await self.ps_items_send_cb(ps_items_send_result, metadata=ps_result)
+
+    async def start(self):
+        if self.args.all and self.args.order_by != C.ORDER_BY_CREATION:
+            self.check_duplicates = True
+            self.items_ids = []
+            self.disp(
+                A.color(
+                    A.FG_RED,
+                    A.BOLD,
+                    '/!\\ "--all" should be used with "--order-by creation" /!\\\n',
+                    A.RESET,
+                    "We'll update items, so order may change during transformation,\n"
+                    "we'll try to mitigate that by stopping on first duplicate,\n"
+                    "but this method is not safe, and some items may be missed.\n---\n",
+                )
+            )
+        else:
+            self.check_duplicates = False
+
+        try:
+            ps_result = data_format.deserialise(
+                await self.host.bridge.ps_items_get(
+                    self.args.service,
+                    self.args.node,
+                    self.args.max,
+                    self.args.items,
+                    "",
+                    self.get_pubsub_extra(),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't retrieve items: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.ps_items_get_cb(ps_result)
+
+
+class Uri(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "uri",
+            use_profile=False,
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("build URI"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-p",
+            "--profile",
+            default=C.PROF_KEY_DEFAULT,
+            help=_("profile (used when no server is specified)"),
+        )
+
+    def display_uri(self, jid_):
+        uri_args = {}
+        if not self.args.service:
+            self.args.service = jid.JID(jid_).bare
+
+        for key in ("node", "service", "item"):
+            value = getattr(self.args, key)
+            if key == "service":
+                key = "path"
+            if value:
+                uri_args[key] = value
+        self.disp(uri.build_xmpp_uri("pubsub", **uri_args))
+        self.host.quit()
+
+    async def start(self):
+        if not self.args.service:
+            try:
+                jid_ = await self.host.bridge.param_get_a_async(
+                    "JabberID", "Connection", profile_key=self.args.profile
+                )
+            except Exception as e:
+                self.disp(f"can't retrieve jid: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            else:
+                self.display_uri(jid_)
+        else:
+            self.display_uri(None)
+
+
+class AttachmentGet(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "get",
+            use_output=C.OUTPUT_LIST_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
+            help=_("get data attached to an item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-j",
+            "--jid",
+            action="append",
+            dest="jids",
+            help=_(
+                "get attached data published only by those JIDs (DEFAULT: get all "
+                "attached data)"
+            )
+        )
+
+    async def start(self):
+        try:
+            attached_data, __ = await self.host.bridge.ps_attachments_get(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.jids or [],
+                "",
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't get attached data: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            attached_data = data_format.deserialise(attached_data, type_check=list)
+            await self.output(attached_data)
+            self.host.quit(C.EXIT_OK)
+
+
+class AttachmentSet(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "set",
+            use_pubsub=True,
+            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
+            help=_("attach data to an item"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--replace",
+            action="store_true",
+            help=_(
+                "replace previous versions of attachments (DEFAULT: update previous "
+                "version)"
+            )
+        )
+        self.parser.add_argument(
+            "-N",
+            "--noticed",
+            metavar="BOOLEAN",
+            nargs="?",
+            default="keep",
+            help=_("mark item as (un)noticed (DEFAULT: keep current value))")
+        )
+        self.parser.add_argument(
+            "-r",
+            "--reactions",
+            # FIXME: to be replaced by "extend" when we stop supporting python 3.7
+            action="append",
+            help=_("emojis to add to react to an item")
+        )
+        self.parser.add_argument(
+            "-R",
+            "--reactions-remove",
+            # FIXME: to be replaced by "extend" when we stop supporting python 3.7
+            action="append",
+            help=_("emojis to remove from reactions to an item")
+        )
+
+    async def start(self):
+        attachments_data = {
+            "service": self.args.service,
+            "node": self.args.node,
+            "id": self.args.item,
+            "extra": {}
+        }
+        operation = "replace" if self.args.replace else "update"
+        if self.args.noticed != "keep":
+            if self.args.noticed is None:
+                self.args.noticed = C.BOOL_TRUE
+            attachments_data["extra"]["noticed"] = C.bool(self.args.noticed)
+
+        if self.args.reactions or self.args.reactions_remove:
+            reactions = attachments_data["extra"]["reactions"] = {
+                "operation": operation
+            }
+            if self.args.replace:
+                reactions["reactions"] = self.args.reactions
+            else:
+                reactions["add"] = self.args.reactions
+                reactions["remove"] = self.args.reactions_remove
+
+
+        if not attachments_data["extra"]:
+            self.parser.error(_("At leat one attachment must be specified."))
+
+        try:
+            await self.host.bridge.ps_attachments_set(
+                data_format.serialise(attachments_data),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't attach data to item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("data attached")
+            self.host.quit(C.EXIT_OK)
+
+
+class Attachments(base.CommandBase):
+    subcommands = (AttachmentGet, AttachmentSet)
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "attachments",
+            use_profile=False,
+            help=_("set or retrieve items attachments"),
+        )
+
+
+class SignatureSign(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "sign",
+            use_pubsub=True,
+            pubsub_flags={C.NODE, C.SINGLE_ITEM},
+            help=_("sign an item"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        attachments_data = {
+            "service": self.args.service,
+            "node": self.args.node,
+            "id": self.args.item,
+            "extra": {
+                # we set None to use profile's bare JID
+                "signature": {"signer": None}
+            }
+        }
+        try:
+            await self.host.bridge.ps_attachments_set(
+                data_format.serialise(attachments_data),
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't sign the item: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(f"item {self.args.item!r} has been signed")
+            self.host.quit(C.EXIT_OK)
+
+
+class SignatureCheck(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "check",
+            use_output=C.OUTPUT_DICT,
+            use_pubsub=True,
+            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
+            help=_("check the validity of pubsub signature"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "signature",
+            metavar="JSON",
+            help=_("signature data")
+        )
+
+    async def start(self):
+        try:
+            ret_s = await self.host.bridge.ps_signature_check(
+                self.args.service,
+                self.args.node,
+                self.args.item,
+                self.args.signature,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't check signature: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self.output(data_format.deserialise((ret_s)))
+            self.host.quit()
+
+
+class Signature(base.CommandBase):
+    subcommands = (
+        SignatureSign,
+        SignatureCheck,
+    )
+
+    def __init__(self, host):
+        super().__init__(
+            host, "signature", use_profile=False, help=_("items signatures")
+        )
+
+
+class SecretShare(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "share",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("share a secret to let other entity encrypt or decrypt items"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-k", "--key", metavar="ID", dest="secret_ids", action="append", default=[],
+            help=_(
+                "only share secrets with those IDs (default: share all secrets of the "
+                "node)"
+            )
+        )
+        self.parser.add_argument(
+            "recipient", metavar="JID", help=_("entity who must get the shared secret")
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_secret_share(
+                self.args.recipient,
+                self.args.service,
+                self.args.node,
+                self.args.secret_ids,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't share secret: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("secrets have been shared")
+            self.host.quit(C.EXIT_OK)
+
+
+class SecretRevoke(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "revoke",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("revoke an encrypted node secret"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "secret_id", help=_("ID of the secrets to revoke")
+        )
+        self.parser.add_argument(
+            "-r", "--recipient", dest="recipients", metavar="JID", action="append",
+            default=[], help=_(
+                "entity who must get the revocation notification (default: send to all "
+                "entities known to have the shared secret)"
+            )
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_secret_revoke(
+                self.args.service,
+                self.args.node,
+                self.args.secret_id,
+                self.args.recipients,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't revoke secret: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("secret {self.args.secret_id} has been revoked.")
+            self.host.quit(C.EXIT_OK)
+
+
+class SecretRotate(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "rotate",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("revoke existing secrets, create a new one and send notifications"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-r", "--recipient", dest="recipients", metavar="JID", action="append",
+            default=[], help=_(
+                "entity who must get the revocation and shared secret notifications "
+                "(default: send to all entities known to have the shared secret)"
+            )
+        )
+
+    async def start(self):
+        try:
+            await self.host.bridge.ps_secret_rotate(
+                self.args.service,
+                self.args.node,
+                self.args.recipients,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't rotate secret: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp("secret has been rotated")
+            self.host.quit(C.EXIT_OK)
+
+
+class SecretList(base.CommandBase):
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "list",
+            use_pubsub=True,
+            use_verbose=True,
+            pubsub_flags={C.NODE},
+            help=_("list known secrets for a pubsub node"),
+            use_output=C.OUTPUT_LIST_DICT
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            secrets = data_format.deserialise(await self.host.bridge.ps_secrets_list(
+                self.args.service,
+                self.args.node,
+                self.profile,
+            ), type_check=list)
+        except Exception as e:
+            self.disp(f"can't list node secrets: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if not self.verbosity:
+                # we don't print key if verbosity is not a least one, to avoid showing it
+                # on the screen accidentally
+                for secret in secrets:
+                    del secret["key"]
+            await self.output(secrets)
+            self.host.quit(C.EXIT_OK)
+
+
+class Secret(base.CommandBase):
+    subcommands = (SecretShare, SecretRevoke, SecretRotate, SecretList)
+
+    def __init__(self, host):
+        super().__init__(
+            host,
+            "secret",
+            use_profile=False,
+            help=_("handle encrypted nodes secrets"),
+        )
+
+
+class HookCreate(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "create",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("create a Pubsub hook"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            default="python",
+            choices=("python", "python_file", "python_code"),
+            help=_("hook type"),
+        )
+        self.parser.add_argument(
+            "-P",
+            "--persistent",
+            action="store_true",
+            help=_("make hook persistent across restarts"),
+        )
+        self.parser.add_argument(
+            "hook_arg",
+            help=_("argument of the hook (depend of the type)"),
+        )
+
+    @staticmethod
+    def check_args(self):
+        if self.args.type == "python_file":
+            self.args.hook_arg = os.path.abspath(self.args.hook_arg)
+            if not os.path.isfile(self.args.hook_arg):
+                self.parser.error(
+                    _("{path} is not a file").format(path=self.args.hook_arg)
+                )
+
+    async def start(self):
+        self.check_args(self)
+        try:
+            await self.host.bridge.ps_hook_add(
+                self.args.service,
+                self.args.node,
+                self.args.type,
+                self.args.hook_arg,
+                self.args.persistent,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't create hook: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.host.quit()
+
+
+class HookDelete(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "delete",
+            use_pubsub=True,
+            pubsub_flags={C.NODE},
+            help=_("delete a Pubsub hook"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-t",
+            "--type",
+            default="",
+            choices=("", "python", "python_file", "python_code"),
+            help=_("hook type to remove, empty to remove all (DEFAULT: remove all)"),
+        )
+        self.parser.add_argument(
+            "-a",
+            "--arg",
+            dest="hook_arg",
+            default="",
+            help=_(
+                "argument of the hook to remove, empty to remove all (DEFAULT: remove all)"
+            ),
+        )
+
+    async def start(self):
+        HookCreate.check_args(self)
+        try:
+            nb_deleted = await self.host.bridge.ps_hook_remove(
+                self.args.service,
+                self.args.node,
+                self.args.type,
+                self.args.hook_arg,
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't delete hook: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(
+                _("{nb_deleted} hook(s) have been deleted").format(nb_deleted=nb_deleted)
+            )
+            self.host.quit()
+
+
+class HookList(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "list",
+            use_output=C.OUTPUT_LIST_DICT,
+            help=_("list hooks of a profile"),
+        )
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            data = await self.host.bridge.ps_hook_list(
+                self.profile,
+            )
+        except Exception as e:
+            self.disp(f"can't list hooks: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            if not data:
+                self.disp(_("No hook found."))
+            await self.output(data)
+            self.host.quit()
+
+
+class Hook(base.CommandBase):
+    subcommands = (HookCreate, HookDelete, HookList)
+
+    def __init__(self, host):
+        super(Hook, self).__init__(
+            host,
+            "hook",
+            use_profile=False,
+            use_verbose=True,
+            help=_("trigger action on Pubsub notifications"),
+        )
+
+
+class Pubsub(base.CommandBase):
+    subcommands = (
+        Set,
+        Get,
+        Delete,
+        Edit,
+        Rename,
+        Subscribe,
+        Unsubscribe,
+        Subscriptions,
+        Affiliations,
+        Reference,
+        Search,
+        Transform,
+        Attachments,
+        Signature,
+        Secret,
+        Hook,
+        Uri,
+        Node,
+        Cache,
+    )
+
+    def __init__(self, host):
+        super(Pubsub, self).__init__(
+            host, "pubsub", use_profile=False, help=_("PubSub nodes/items management")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_roster.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,327 @@
+#!/usr/bin/env python3
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2003-2016 Adrien Cossa (souliane@mailoo.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 . import base
+from collections import OrderedDict
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.tools import jid
+from libervia.backend.tools.common.ansi import ANSI as A
+
+__commands__ = ["Roster"]
+
+
+class Get(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(
+            host, 'get', use_output=C.OUTPUT_DICT, use_verbose=True,
+            extra_outputs = {"default": self.default_output},
+            help=_('retrieve the roster entities'))
+
+    def add_parser_options(self):
+        pass
+
+    def default_output(self, data):
+        for contact_jid, contact_data in data.items():
+            all_keys = list(contact_data.keys())
+            keys_to_show = []
+            name = contact_data.get('name', contact_jid.node)
+
+            if self.verbosity >= 1:
+                keys_to_show.append('groups')
+                all_keys.remove('groups')
+            if self.verbosity >= 2:
+                keys_to_show.extend(all_keys)
+
+            if name is None:
+                self.disp(A.color(C.A_HEADER, contact_jid))
+            else:
+                self.disp(A.color(C.A_HEADER, name, A.RESET, f" ({contact_jid})"))
+            for k in keys_to_show:
+                value = contact_data[k]
+                if value:
+                    if isinstance(value, list):
+                        value = ', '.join(value)
+                    self.disp(A.color(
+                        "    ", C.A_SUBHEADER, f"{k}: ", A.RESET, str(value)))
+
+    async def start(self):
+        try:
+            contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile)
+        except Exception as e:
+            self.disp(f"error while retrieving the contacts: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        contacts_dict = {}
+        for contact_jid_s, data, groups in contacts:
+            # FIXME: we have to convert string to bool here for historical reason
+            #        contacts_get format should be changed and serialised properly
+            for key in ('from', 'to', 'ask'):
+                if key in data:
+                    data[key] = C.bool(data[key])
+            data['groups'] = list(groups)
+            contacts_dict[jid.JID(contact_jid_s)] = data
+
+        await self.output(contacts_dict)
+        self.host.quit()
+
+
+class Set(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(host, 'set', help=_('set metadata for a roster entity'))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-n", "--name", default="", help=_('name to use for this entity'))
+        self.parser.add_argument(
+            "-g", "--group", dest='groups', action='append', metavar='GROUP', default=[],
+            help=_('groups for this entity'))
+        self.parser.add_argument(
+            "-R", "--replace", action="store_true",
+            help=_("replace all metadata instead of adding them"))
+        self.parser.add_argument(
+            "jid", help=_("jid of the roster entity"))
+
+    async def start(self):
+
+        if self.args.replace:
+            name = self.args.name
+            groups = self.args.groups
+        else:
+            try:
+                entity_data = await self.host.bridge.contact_get(
+                    self.args.jid, self.host.profile)
+            except Exception as e:
+                self.disp(f"error while retrieving the contact: {e}", error=True)
+                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+            name = self.args.name or entity_data[0].get('name') or ''
+            groups = set(entity_data[1])
+            groups = list(groups.union(self.args.groups))
+
+        try:
+            await self.host.bridge.contact_update(
+                self.args.jid, name, groups, self.host.profile)
+        except Exception as e:
+            self.disp(f"error while updating the contact: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        self.host.quit()
+
+
+class Delete(base.CommandBase):
+
+    def __init__(self, host):
+        super().__init__(host, 'delete', help=_('remove an entity from roster'))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "-f", "--force", action="store_true", help=_("delete without confirmation")
+        )
+        self.parser.add_argument(
+            "jid", help=_("jid of the roster entity"))
+
+    async def start(self):
+        if not self.args.force:
+            message = _("Are you sure to delete {entity} from your roster?").format(
+                entity=self.args.jid
+            )
+            await self.host.confirm_or_quit(message, _("entity deletion cancelled"))
+        try:
+            await self.host.bridge.contact_del(
+                self.args.jid, self.host.profile)
+        except Exception as e:
+            self.disp(f"error while deleting the entity: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        self.host.quit()
+
+
+class Stats(base.CommandBase):
+
+    def __init__(self, host):
+        super(Stats, self).__init__(host, 'stats', help=_('Show statistics about a roster'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile)
+        except Exception as e:
+            self.disp(f"error while retrieving the contacts: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        hosts = {}
+        unique_groups = set()
+        no_sub, no_from, no_to, no_group, total_group_subscription = 0, 0, 0, 0, 0
+        for contact, attrs, groups in contacts:
+            from_, to = C.bool(attrs["from"]), C.bool(attrs["to"])
+            if not from_:
+                if not to:
+                    no_sub += 1
+                else:
+                    no_from += 1
+            elif not to:
+                no_to += 1
+
+            host = jid.JID(contact).domain
+
+            hosts.setdefault(host, 0)
+            hosts[host] += 1
+            if groups:
+                unique_groups.update(groups)
+                total_group_subscription += len(groups)
+            if not groups:
+                no_group += 1
+        hosts = OrderedDict(sorted(list(hosts.items()), key=lambda item:-item[1]))
+
+        print()
+        print("Total number of contacts: %d" % len(contacts))
+        print("Number of different hosts: %d" % len(hosts))
+        print()
+        for host, count in hosts.items():
+            print("Contacts on {host}: {count} ({rate:.1f}%)".format(
+                host=host, count=count, rate=100 * float(count) / len(contacts)))
+        print()
+        print("Contacts with no 'from' subscription: %d" % no_from)
+        print("Contacts with no 'to' subscription: %d" % no_to)
+        print("Contacts with no subscription at all: %d" % no_sub)
+        print()
+        print("Total number of groups: %d" % len(unique_groups))
+        try:
+            contacts_per_group = float(total_group_subscription) / len(unique_groups)
+        except ZeroDivisionError:
+            contacts_per_group = 0
+        print("Average contacts per group: {:.1f}".format(contacts_per_group))
+        try:
+            groups_per_contact = float(total_group_subscription) / len(contacts)
+        except ZeroDivisionError:
+            groups_per_contact = 0
+        print(f"Average groups' subscriptions per contact: {groups_per_contact:.1f}")
+        print("Contacts not assigned to any group: %d" % no_group)
+        self.host.quit()
+
+
+class Purge(base.CommandBase):
+
+    def __init__(self, host):
+        super(Purge, self).__init__(
+            host, 'purge',
+            help=_('purge the roster from its contacts with no subscription'))
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "--no-from", action="store_true",
+            help=_("also purge contacts with no 'from' subscription"))
+        self.parser.add_argument(
+            "--no-to", action="store_true",
+            help=_("also purge contacts with no 'to' subscription"))
+
+    async def start(self):
+        try:
+            contacts = await self.host.bridge.contacts_get(self.host.profile)
+        except Exception as e:
+            self.disp(f"error while retrieving the contacts: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+        no_sub, no_from, no_to = [], [], []
+        for contact, attrs, groups in contacts:
+            from_, to = C.bool(attrs["from"]), C.bool(attrs["to"])
+            if not from_:
+                if not to:
+                    no_sub.append(contact)
+                elif self.args.no_from:
+                    no_from.append(contact)
+            elif not to and self.args.no_to:
+                no_to.append(contact)
+        if not no_sub and not no_from and not no_to:
+            self.disp(
+                f"Nothing to do - there's a from and/or to subscription(s) between "
+                f"profile {self.host.profile!r} and each of its contacts"
+            )
+        elif await self.ask_confirmation(no_sub, no_from, no_to):
+            for contact in no_sub + no_from + no_to:
+                try:
+                    await self.host.bridge.contact_del(
+                        contact, profile_key=self.host.profile)
+                except Exception as e:
+                    self.disp(f"can't delete contact {contact!r}: {e}", error=True)
+                else:
+                    self.disp(f"contact {contact!r} has been removed")
+
+        self.host.quit()
+
+    async def ask_confirmation(self, no_sub, no_from, no_to):
+        """Ask the confirmation before removing contacts.
+
+        @param no_sub (list[unicode]): list of contacts with no subscription
+        @param no_from (list[unicode]): list of contacts with no 'from' subscription
+        @param no_to (list[unicode]): list of contacts with no 'to' subscription
+        @return bool
+        """
+        if no_sub:
+            self.disp(
+                f"There's no subscription between profile {self.host.profile!r} and the "
+                f"following contacts:")
+            self.disp("    " + "\n    ".join(no_sub))
+        if no_from:
+            self.disp(
+                f"There's no 'from' subscription between profile {self.host.profile!r} "
+                f"and the following contacts:")
+            self.disp("    " + "\n    ".join(no_from))
+        if no_to:
+            self.disp(
+                f"There's no 'to' subscription between profile {self.host.profile!r} and "
+                f"the following contacts:")
+            self.disp("    " + "\n    ".join(no_to))
+        message = f"REMOVE them from profile {self.host.profile}'s roster"
+        while True:
+            res = await self.host.ainput(f"{message} (y/N)? ")
+            if not res or res.lower() == 'n':
+                return False
+            if res.lower() == 'y':
+                return True
+
+
+class Resync(base.CommandBase):
+
+    def __init__(self, host):
+        super(Resync, self).__init__(
+            host, 'resync', help=_('do a full resynchronisation of roster with server'))
+
+    def add_parser_options(self):
+        pass
+
+    async def start(self):
+        try:
+            await self.host.bridge.roster_resync(profile_key=self.host.profile)
+        except Exception as e:
+            self.disp(f"can't resynchronise roster: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            self.disp(_("Roster resynchronized"))
+            self.host.quit(C.EXIT_OK)
+
+
+class Roster(base.CommandBase):
+    subcommands = (Get, Set, Delete, Stats, Purge, Resync)
+
+    def __init__(self, host):
+        super(Roster, self).__init__(
+            host, 'roster', use_profile=True, help=_("Manage an entity's roster"))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_shell.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+
+import cmd
+import sys
+import shlex
+import subprocess
+from . import base
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.jp import arg_tools
+from libervia.backend.tools.common.ansi import ANSI as A
+
+__commands__ = ["Shell"]
+INTRO = _(
+    """Welcome to {app_name} shell, the Salut à Toi shell !
+
+This enrironment helps you using several {app_name} commands with similar parameters.
+
+To quit, just enter "quit" or press C-d.
+Enter "help" or "?" to know what to do
+"""
+).format(app_name=C.APP_NAME)
+
+
+class Shell(base.CommandBase, cmd.Cmd):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "shell",
+            help=_("launch jp in shell (REPL) mode")
+        )
+        cmd.Cmd.__init__(self)
+
+    def parse_args(self, args):
+        """parse line arguments"""
+        return shlex.split(args, posix=True)
+
+    def update_path(self):
+        self._cur_parser = self.host.parser
+        self.help = ""
+        for idx, path_elt in enumerate(self.path):
+            try:
+                self._cur_parser = arg_tools.get_cmd_choices(path_elt, self._cur_parser)
+            except exceptions.NotFound:
+                self.disp(_("bad command path"), error=True)
+                self.path = self.path[:idx]
+                break
+            else:
+                self.help = self._cur_parser
+
+        self.prompt = A.color(C.A_PROMPT_PATH, "/".join(self.path)) + A.color(
+            C.A_PROMPT_SUF, "> "
+        )
+        try:
+            self.actions = list(arg_tools.get_cmd_choices(parser=self._cur_parser).keys())
+        except exceptions.NotFound:
+            self.actions = []
+
+    def add_parser_options(self):
+        pass
+
+    def format_args(self, args):
+        """format argument to be printed with quotes if needed"""
+        for arg in args:
+            if " " in arg:
+                yield arg_tools.escape(arg)
+            else:
+                yield arg
+
+    def run_cmd(self, args, external=False):
+        """run command and retur exit code
+
+        @param args[list[string]]: arguments of the command
+            must not include program name
+        @param external(bool): True if it's an external command (i.e. not jp)
+        @return (int): exit code (0 success, any other int failure)
+        """
+        # FIXME: we have to use subprocess
+        # and relaunch whole python for now
+        # because if host.quit() is called in D-Bus callback
+        # GLib quit the whole app without possibility to stop it
+        # didn't found a nice way to work around it so far
+        # Situation should be better when we'll move away from python-dbus
+        if self.verbose:
+            self.disp(
+                _("COMMAND {external}=> {args}").format(
+                    external=_("(external) ") if external else "",
+                    args=" ".join(self.format_args(args)),
+                )
+            )
+        if not external:
+            args = sys.argv[0:1] + args
+        ret_code = subprocess.call(args)
+        # XXX: below is a way to launch the command without creating a new process
+        #      may be used when a solution to the aforementioned issue is there
+        # try:
+        #     self.host._run(args)
+        # except SystemExit as e:
+        #     ret_code = e.code
+        # except Exception as e:
+        #     self.disp(A.color(C.A_FAILURE, u'command failed with an exception: {msg}'.format(msg=e)), error=True)
+        #     ret_code = 1
+        # else:
+        #     ret_code = 0
+
+        if ret_code != 0:
+            self.disp(
+                A.color(
+                    C.A_FAILURE,
+                    "command failed with an error code of {err_no}".format(
+                        err_no=ret_code
+                    ),
+                ),
+                error=True,
+            )
+        return ret_code
+
+    def default(self, args):
+        """called when no shell command is recognized
+
+        will launch the command with args on the line
+        (i.e. will launch do [args])
+        """
+        if args == "EOF":
+            self.do_quit("")
+        self.do_do(args)
+
+    def do_help(self, args):
+        """show help message"""
+        if not args:
+            self.disp(A.color(C.A_HEADER, _("Shell commands:")), end=' ')
+        super(Shell, self).do_help(args)
+        if not args:
+            self.disp(A.color(C.A_HEADER, _("Action commands:")))
+            help_list = self._cur_parser.format_help().split("\n\n")
+            print(("\n\n".join(help_list[1 if self.path else 2 :])))
+
+    # FIXME: debug crashes on exit and is not that useful,
+    #        keeping it until refactoring, may be removed entirely then
+    # def do_debug(self, args):
+    #     """launch internal debugger"""
+    #     try:
+    #         import ipdb as pdb
+    #     except ImportError:
+    #         import pdb
+    #     pdb.set_trace()
+
+    def do_verbose(self, args):
+        """show verbose mode, or (de)activate it"""
+        args = self.parse_args(args)
+        if args:
+            self.verbose = C.bool(args[0])
+        self.disp(
+            _("verbose mode is {status}").format(
+                status=_("ENABLED") if self.verbose else _("DISABLED")
+            )
+        )
+
+    def do_cmd(self, args):
+        """change command path"""
+        if args == "..":
+            self.path = self.path[:-1]
+        else:
+            if not args or args[0] == "/":
+                self.path = []
+            args = "/".join(args.split())
+            for path_elt in args.split("/"):
+                path_elt = path_elt.strip()
+                if not path_elt:
+                    continue
+                self.path.append(path_elt)
+        self.update_path()
+
+    def do_version(self, args):
+        """show current SàT/jp version"""
+        self.run_cmd(['--version'])
+
+    def do_shell(self, args):
+        """launch an external command (you can use ![command] too)"""
+        args = self.parse_args(args)
+        self.run_cmd(args, external=True)
+
+    def do_do(self, args):
+        """lauch a command"""
+        args = self.parse_args(args)
+        if (
+            self._not_default_profile
+            and not "-p" in args
+            and not "--profile" in args
+            and not "profile" in self.use
+        ):
+            # profile is not specified and we are not using the default profile
+            # so we need to add it in arguments to use current user profile
+            if self.verbose:
+                self.disp(
+                    _("arg profile={profile} (logged profile)").format(
+                        profile=self.profile
+                    )
+                )
+            use = self.use.copy()
+            use["profile"] = self.profile
+        else:
+            use = self.use
+
+        # args may be modified by use_args
+        # to remove subparsers from it
+        parser_args, use_args = arg_tools.get_use_args(
+            self.host, args, use, verbose=self.verbose, parser=self._cur_parser
+        )
+        cmd_args = self.path + parser_args + use_args
+        self.run_cmd(cmd_args)
+
+    def do_use(self, args):
+        """fix an argument"""
+        args = self.parse_args(args)
+        if not args:
+            if not self.use:
+                self.disp(_("no argument in USE"))
+            else:
+                self.disp(_("arguments in USE:"))
+                for arg, value in self.use.items():
+                    self.disp(
+                        _(
+                            A.color(
+                                C.A_SUBHEADER,
+                                arg,
+                                A.RESET,
+                                " = ",
+                                arg_tools.escape(value),
+                            )
+                        )
+                    )
+        elif len(args) != 2:
+            self.disp("bad syntax, please use:\nuse [arg] [value]", error=True)
+        else:
+            self.use[args[0]] = " ".join(args[1:])
+            if self.verbose:
+                self.disp(
+                    "set {name} = {value}".format(
+                        name=args[0], value=arg_tools.escape(args[1])
+                    )
+                )
+
+    def do_use_clear(self, args):
+        """unset one or many argument(s) in USE, or all of them if no arg is specified"""
+        args = self.parse_args(args)
+        if not args:
+            self.use.clear()
+        else:
+            for arg in args:
+                try:
+                    del self.use[arg]
+                except KeyError:
+                    self.disp(
+                        A.color(
+                            C.A_FAILURE, _("argument {name} not found").format(name=arg)
+                        ),
+                        error=True,
+                    )
+                else:
+                    if self.verbose:
+                        self.disp(_("argument {name} removed").format(name=arg))
+
+    def do_whoami(self, args):
+        """print profile currently used"""
+        self.disp(self.profile)
+
+    def do_quit(self, args):
+        """quit the shell"""
+        self.disp(_("good bye!"))
+        self.host.quit()
+
+    def do_exit(self, args):
+        """alias for quit"""
+        self.do_quit(args)
+
+    async def start(self):
+        # FIXME: "shell" is currently kept synchronous as it works well as it
+        #        and it will be refactored soon.
+        default_profile = self.host.bridge.profile_name_get(C.PROF_KEY_DEFAULT)
+        self._not_default_profile = self.profile != default_profile
+        self.path = []
+        self._cur_parser = self.host.parser
+        self.use = {}
+        self.verbose = False
+        self.update_path()
+        self.cmdloop(INTRO)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/cmd_uri.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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 . import base
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.tools.common import uri
+
+__commands__ = ["Uri"]
+
+
+class Parse(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self,
+            host,
+            "parse",
+            use_profile=False,
+            use_output=C.OUTPUT_DICT,
+            help=_("parse URI"),
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument(
+            "uri", help=_("XMPP URI to parse")
+        )
+
+    async def start(self):
+        await self.output(uri.parse_xmpp_uri(self.args.uri))
+        self.host.quit()
+
+
+class Build(base.CommandBase):
+    def __init__(self, host):
+        base.CommandBase.__init__(
+            self, host, "build", use_profile=False, help=_("build URI")
+        )
+
+    def add_parser_options(self):
+        self.parser.add_argument("type", help=_("URI type"))
+        self.parser.add_argument("path", help=_("URI path"))
+        self.parser.add_argument(
+            "-f",
+            "--field",
+            action="append",
+            nargs=2,
+            dest="fields",
+            metavar=("KEY", "VALUE"),
+            help=_("URI fields"),
+        )
+
+    async def start(self):
+        fields = dict(self.args.fields) if self.args.fields else {}
+        self.disp(uri.build_xmpp_uri(self.args.type, path=self.args.path, **fields))
+        self.host.quit()
+
+
+class Uri(base.CommandBase):
+    subcommands = (Parse, Build)
+
+    def __init__(self, host):
+        super(Uri, self).__init__(
+            host, "uri", use_profile=False, help=_("XMPP URI parsing/generation")
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/common.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,833 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+import json
+import os
+import os.path
+import time
+import tempfile
+import asyncio
+import shlex
+import re
+from pathlib import Path
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import regex
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import uri as xmpp_uri
+from libervia.backend.tools import config
+from configparser import NoSectionError, NoOptionError
+from collections import namedtuple
+
+# default arguments used for some known editors (editing with metadata)
+VIM_SPLIT_ARGS = "-c 'set nospr|vsplit|wincmd w|next|wincmd w'"
+EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"'
+EDITOR_ARGS_MAGIC = {
+    "vim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}",
+    "nvim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}",
+    "gvim": VIM_SPLIT_ARGS + " --nofork {content_file} {metadata_file}",
+    "emacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}",
+    "xemacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}",
+    "nano": " -F {content_file} {metadata_file}",
+}
+
+SECURE_UNLINK_MAX = 10
+SECURE_UNLINK_DIR = ".backup"
+METADATA_SUFF = "_metadata.json"
+
+
+def format_time(timestamp):
+    """Return formatted date for timestamp
+
+    @param timestamp(str,int,float): unix timestamp
+    @return (unicode): formatted date
+    """
+    fmt = "%d/%m/%Y %H:%M:%S %Z"
+    return time.strftime(fmt, time.localtime(float(timestamp)))
+
+
+def ansi_ljust(s, width):
+    """ljust method handling ANSI escape codes"""
+    cleaned = regex.ansi_remove(s)
+    return s + " " * (width - len(cleaned))
+
+
+def ansi_center(s, width):
+    """ljust method handling ANSI escape codes"""
+    cleaned = regex.ansi_remove(s)
+    diff = width - len(cleaned)
+    half = diff / 2
+    return half * " " + s + (half + diff % 2) * " "
+
+
+def ansi_rjust(s, width):
+    """ljust method handling ANSI escape codes"""
+    cleaned = regex.ansi_remove(s)
+    return " " * (width - len(cleaned)) + s
+
+
+def get_tmp_dir(sat_conf, cat_dir, sub_dir=None):
+    """Return directory used to store temporary files
+
+    @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
+    @param cat_dir(str): directory of the category (e.g. "blog")
+    @param sub_dir(str): sub directory where data need to be put
+        profile can be used here, or special directory name
+        sub_dir will be escaped to be usable in path (use regex.path_unescape to find
+        initial str)
+    @return (Path): path to the dir
+    """
+    local_dir = config.config_get(sat_conf, "", "local_dir", Exception)
+    path_elts = [local_dir, cat_dir]
+    if sub_dir is not None:
+        path_elts.append(regex.path_escape(sub_dir))
+    return Path(*path_elts)
+
+
+def parse_args(host, cmd_line, **format_kw):
+    """Parse command arguments
+
+    @param cmd_line(unicode): command line as found in sat.conf
+    @param format_kw: keywords used for formating
+    @return (list(unicode)): list of arguments to pass to subprocess function
+    """
+    try:
+        # we split the arguments and add the known fields
+        # we split arguments first to avoid escaping issues in file names
+        return [a.format(**format_kw) for a in shlex.split(cmd_line)]
+    except ValueError as e:
+        host.disp(
+            "Couldn't parse editor cmd [{cmd}]: {reason}".format(cmd=cmd_line, reason=e)
+        )
+        return []
+
+
+class BaseEdit(object):
+    """base class for editing commands
+
+    This class allows to edit file for PubSub or something else.
+    It works with temporary files in SàT local_dir, in a "cat_dir" subdir
+    """
+
+    def __init__(self, host, cat_dir, use_metadata=False):
+        """
+        @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
+        @param cat_dir(unicode): directory to use for drafts
+            this will be a sub-directory of SàT's local_dir
+        @param use_metadata(bool): True is edition need a second file for metadata
+            most of signature change with use_metadata with an additional metadata
+            argument.
+            This is done to raise error if a command needs metadata but forget the flag,
+            and vice versa
+        """
+        self.host = host
+        self.cat_dir = cat_dir
+        self.use_metadata = use_metadata
+
+    def secure_unlink(self, path):
+        """Unlink given path after keeping it for a while
+
+        This method is used to prevent accidental deletion of a draft
+        If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX,
+        older file are deleted
+        @param path(Path, str): file to unlink
+        """
+        path = Path(path).resolve()
+        if not path.is_file:
+            raise OSError("path must link to a regular file")
+        if path.parent != get_tmp_dir(self.sat_conf, self.cat_dir):
+            self.disp(
+                f"File {path} is not in SàT temporary hierarchy, we do not remove " f"it",
+                2,
+            )
+            return
+            # we have 2 files per draft with use_metadata, so we double max
+        unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX
+        backup_dir = get_tmp_dir(self.sat_conf, self.cat_dir, SECURE_UNLINK_DIR)
+        if not os.path.exists(backup_dir):
+            os.makedirs(backup_dir)
+        filename = os.path.basename(path)
+        backup_path = os.path.join(backup_dir, filename)
+        # we move file to backup dir
+        self.host.disp(
+            "Backuping file {src} to {dst}".format(src=path, dst=backup_path),
+            1,
+        )
+        os.rename(path, backup_path)
+        # and if we exceeded the limit, we remove older file
+        backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)]
+        if len(backup_files) > unlink_max:
+            backup_files.sort(key=lambda path: os.stat(path).st_mtime)
+            for path in backup_files[: len(backup_files) - unlink_max]:
+                self.host.disp("Purging backup file {}".format(path), 2)
+                os.unlink(path)
+
+    async def run_editor(
+        self,
+        editor_args_opt,
+        content_file_path,
+        content_file_obj,
+        meta_file_path=None,
+        meta_ori=None,
+    ):
+        """Run editor to edit content and metadata
+
+        @param editor_args_opt(unicode): option in [jp] section in configuration for
+            specific args
+        @param content_file_path(str): path to the content file
+        @param content_file_obj(file): opened file instance
+        @param meta_file_path(str, Path, None): metadata file path
+            if None metadata will not be used
+        @param meta_ori(dict, None): original cotent of metadata
+            can't be used if use_metadata is False
+        """
+        if not self.use_metadata:
+            assert meta_file_path is None
+            assert meta_ori is None
+
+            # we calculate hashes to check for modifications
+        import hashlib
+
+        content_file_obj.seek(0)
+        tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest()
+        content_file_obj.close()
+
+        # we prepare arguments
+        editor = config.config_get(self.sat_conf, C.CONFIG_SECTION, "editor") or os.getenv(
+            "EDITOR", "vi"
+        )
+        try:
+            # is there custom arguments in sat.conf ?
+            editor_args = config.config_get(
+                self.sat_conf, C.CONFIG_SECTION, editor_args_opt, Exception
+            )
+        except (NoOptionError, NoSectionError):
+            # no, we check if we know the editor and have special arguments
+            if self.use_metadata:
+                editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), "")
+            else:
+                editor_args = ""
+        parse_kwargs = {"content_file": content_file_path}
+        if self.use_metadata:
+            parse_kwargs["metadata_file"] = meta_file_path
+        args = parse_args(self.host, editor_args, **parse_kwargs)
+        if not args:
+            args = [content_file_path]
+
+            # actual editing
+        editor_process = await asyncio.create_subprocess_exec(
+            editor, *[str(a) for a in args]
+        )
+        editor_exit = await editor_process.wait()
+
+        # edition will now be checked, and data will be sent if it was a success
+        if editor_exit != 0:
+            self.disp(
+                f"Editor exited with an error code, so temporary file has not be "
+                f"deleted, and item is not published.\nYou can find temporary file "
+                f"at {content_file_path}",
+                error=True,
+            )
+        else:
+            # main content
+            try:
+                with content_file_path.open("rb") as f:
+                    content = f.read()
+            except (OSError, IOError):
+                self.disp(
+                    f"Can read file at {content_file_path}, have it been deleted?\n"
+                    f"Cancelling edition",
+                    error=True,
+                )
+                self.host.quit(C.EXIT_NOT_FOUND)
+
+                # metadata
+            if self.use_metadata:
+                try:
+                    with meta_file_path.open("rb") as f:
+                        metadata = json.load(f)
+                except (OSError, IOError):
+                    self.disp(
+                        f"Can read file at {meta_file_path}, have it been deleted?\n"
+                        f"Cancelling edition",
+                        error=True,
+                    )
+                    self.host.quit(C.EXIT_NOT_FOUND)
+                except ValueError:
+                    self.disp(
+                        f"Can't parse metadata, please check it is correct JSON format. "
+                        f"Cancelling edition.\nYou can find tmp file at "
+                        f"{content_file_path} and temporary meta file at "
+                        f"{meta_file_path}.",
+                        error=True,
+                    )
+                    self.host.quit(C.EXIT_DATA_ERROR)
+
+            if self.use_metadata and not metadata.get("publish", True):
+                self.disp(
+                    f'Publication blocked by "publish" key in metadata, cancelling '
+                    f"edition.\n\ntemporary file path:\t{content_file_path}\nmetadata "
+                    f"file path:\t{meta_file_path}",
+                    error=True,
+                )
+                self.host.quit()
+
+            if len(content) == 0:
+                self.disp("Content is empty, cancelling the edition")
+                if content_file_path.parent != get_tmp_dir(self.sat_conf, self.cat_dir):
+                    self.disp(
+                        "File are not in SàT temporary hierarchy, we do not remove them",
+                        2,
+                    )
+                    self.host.quit()
+                self.disp(f"Deletion of {content_file_path}", 2)
+                os.unlink(content_file_path)
+                if self.use_metadata:
+                    self.disp(f"Deletion of {meta_file_path}".format(meta_file_path), 2)
+                    os.unlink(meta_file_path)
+                self.host.quit()
+
+                # time to re-check the hash
+            elif tmp_ori_hash == hashlib.sha1(content).digest() and (
+                not self.use_metadata or meta_ori == metadata
+            ):
+                self.disp("The content has not been modified, cancelling the edition")
+                self.host.quit()
+
+            else:
+                # we can now send the item
+                content = content.decode("utf-8-sig")  # we use utf-8-sig to avoid BOM
+                try:
+                    if self.use_metadata:
+                        await self.publish(content, metadata)
+                    else:
+                        await self.publish(content)
+                except Exception as e:
+                    if self.use_metadata:
+                        self.disp(
+                            f"Error while sending your item, the temporary files have "
+                            f"been kept at {content_file_path} and {meta_file_path}: "
+                            f"{e}",
+                            error=True,
+                        )
+                    else:
+                        self.disp(
+                            f"Error while sending your item, the temporary file has been "
+                            f"kept at {content_file_path}: {e}",
+                            error=True,
+                        )
+                    self.host.quit(1)
+
+            self.secure_unlink(content_file_path)
+            if self.use_metadata:
+                self.secure_unlink(meta_file_path)
+
+    async def publish(self, content):
+        # if metadata is needed, publish will be called with it last argument
+        raise NotImplementedError
+
+    def get_tmp_file(self):
+        """Create a temporary file
+
+        @return (tuple(file, Path)): opened (w+b) file object and file path
+        """
+        suff = "." + self.get_tmp_suff()
+        cat_dir_str = self.cat_dir
+        tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, self.profile)
+        if not tmp_dir.exists():
+            try:
+                tmp_dir.mkdir(parents=True)
+            except OSError as e:
+                self.disp(
+                    f"Can't create {tmp_dir} directory: {e}",
+                    error=True,
+                )
+                self.host.quit(1)
+        try:
+            fd, path = tempfile.mkstemp(
+                suffix=suff,
+                prefix=time.strftime(cat_dir_str + "_%Y-%m-%d_%H:%M:%S_"),
+                dir=tmp_dir,
+                text=True,
+            )
+            return os.fdopen(fd, "w+b"), Path(path)
+        except OSError as e:
+            self.disp(f"Can't create temporary file: {e}", error=True)
+            self.host.quit(1)
+
+    def get_current_file(self, profile):
+        """Get most recently edited file
+
+        @param profile(unicode): profile linked to the draft
+        @return(Path): full path of current file
+        """
+        # we guess the item currently edited by choosing
+        # the most recent file corresponding to temp file pattern
+        # in tmp_dir, excluding metadata files
+        tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, profile)
+        available = [
+            p
+            for p in tmp_dir.glob(f"{self.cat_dir}_*")
+            if not p.match(f"*{METADATA_SUFF}")
+        ]
+        if not available:
+            self.disp(
+                f"Could not find any content draft in {tmp_dir}",
+                error=True,
+            )
+            self.host.quit(1)
+        return max(available, key=lambda p: p.stat().st_mtime)
+
+    async def get_item_data(self, service, node, item):
+        """return formatted content, metadata (or not if use_metadata is false), and item id"""
+        raise NotImplementedError
+
+    def get_tmp_suff(self):
+        """return suffix used for content file"""
+        return "xml"
+
+    async def get_item_path(self):
+        """Retrieve item path (i.e. service and node) from item argument
+
+        This method is obviously only useful for edition of PubSub based features
+        """
+        service = self.args.service
+        node = self.args.node
+        item = self.args.item
+        last_item = self.args.last_item
+
+        if self.args.current:
+            # user wants to continue current draft
+            content_file_path = self.get_current_file(self.profile)
+            self.disp("Continuing edition of current draft", 2)
+            content_file_obj = content_file_path.open("r+b")
+            # we seek at the end of file in case of an item already exist
+            # this will write content of the existing item at the end of the draft.
+            # This way no data should be lost.
+            content_file_obj.seek(0, os.SEEK_END)
+        elif self.args.draft_path:
+            # there is an existing draft that we use
+            content_file_path = self.args.draft_path.expanduser()
+            content_file_obj = content_file_path.open("r+b")
+            # we seek at the end for the same reason as above
+            content_file_obj.seek(0, os.SEEK_END)
+        else:
+            # we need a temporary file
+            content_file_obj, content_file_path = self.get_tmp_file()
+
+        if item or last_item:
+            self.disp("Editing requested published item", 2)
+            try:
+                if self.use_metadata:
+                    content, metadata, item = await self.get_item_data(service, node, item)
+                else:
+                    content, item = await self.get_item_data(service, node, item)
+            except Exception as e:
+                # FIXME: ugly but we have not good may to check errors in bridge
+                if "item-not-found" in str(e):
+                    #  item doesn't exist, we create a new one with requested id
+                    metadata = None
+                    if last_item:
+                        self.disp(_("no item found at all, we create a new one"), 2)
+                    else:
+                        self.disp(
+                            _(
+                                'item "{item}" not found, we create a new item with'
+                                "this id"
+                            ).format(item=item),
+                            2,
+                        )
+                    content_file_obj.seek(0)
+                else:
+                    self.disp(f"Error while retrieving item: {e}")
+                    self.host.quit(C.EXIT_ERROR)
+            else:
+                # item exists, we write content
+                if content_file_obj.tell() != 0:
+                    # we already have a draft,
+                    # we copy item content after it and add an indicator
+                    content_file_obj.write("\n*****\n")
+                content_file_obj.write(content.encode("utf-8"))
+                content_file_obj.seek(0)
+                self.disp(_('item "{item}" found, we edit it').format(item=item), 2)
+        else:
+            self.disp("Editing a new item", 2)
+            if self.use_metadata:
+                metadata = None
+
+        if self.use_metadata:
+            return service, node, item, content_file_path, content_file_obj, metadata
+        else:
+            return service, node, item, content_file_path, content_file_obj
+
+
+class Table(object):
+    def __init__(self, host, data, headers=None, filters=None, use_buffer=False):
+        """
+        @param data(iterable[list]): table data
+            all lines must have the same number of columns
+        @param headers(iterable[unicode], None): names/titles of the columns
+            if not None, must have same number of columns as data
+        @param filters(iterable[(callable, unicode)], None): values filters
+            the callable will get 2 arguments:
+                - current column value
+                - RowData with all columns values
+            if may also only use 1 argument, which will then be current col value.
+            the callable must return a string
+            if it's unicode, it will be used with .format and must countain u'{}' which
+            will be replaced with the string.
+            if not None, must have same number of columns as data
+        @param use_buffer(bool): if True, bufferise output instead of printing it directly
+        """
+        self.host = host
+        self._buffer = [] if use_buffer else None
+        #  headers are columns names/titles, can be None
+        self.headers = headers
+        #  sizes fof columns without headers,
+        # headers may be larger
+        self.sizes = []
+        #  rows countains one list per row with columns values
+        self.rows = []
+
+        size = None
+        if headers:
+            # we use a namedtuple to make the value easily accessible from filters
+            headers_safe = [re.sub(r"[^a-zA-Z_]", "_", h) for h in headers]
+            row_cls = namedtuple("RowData", headers_safe)
+        else:
+            row_cls = tuple
+
+        for row_data in data:
+            new_row = []
+            row_data_list = list(row_data)
+            for idx, value in enumerate(row_data_list):
+                if filters is not None and filters[idx] is not None:
+                    filter_ = filters[idx]
+                    if isinstance(filter_, str):
+                        col_value = filter_.format(value)
+                    else:
+                        try:
+                            col_value = filter_(value, row_cls(*row_data_list))
+                        except TypeError:
+                            col_value = filter_(value)
+                            # we count size without ANSI code as they will change length of the
+                            # string when it's mostly style/color changes.
+                    col_size = len(regex.ansi_remove(col_value))
+                else:
+                    col_value = str(value)
+                    col_size = len(col_value)
+                new_row.append(col_value)
+                if size is None:
+                    self.sizes.append(col_size)
+                else:
+                    self.sizes[idx] = max(self.sizes[idx], col_size)
+            if size is None:
+                size = len(new_row)
+                if headers is not None and len(headers) != size:
+                    raise exceptions.DataError("headers size is not coherent with rows")
+            else:
+                if len(new_row) != size:
+                    raise exceptions.DataError("rows size is not coherent")
+            self.rows.append(new_row)
+
+        if not data and headers is not None:
+            #  the table is empty, we print headers at their lenght
+            self.sizes = [len(h) for h in headers]
+
+    @property
+    def string(self):
+        if self._buffer is None:
+            raise exceptions.InternalError("buffer must be used to get a string")
+        return "\n".join(self._buffer)
+
+    @staticmethod
+    def read_dict_values(data, keys, defaults=None):
+        if defaults is None:
+            defaults = {}
+        for key in keys:
+            try:
+                yield data[key]
+            except KeyError as e:
+                default = defaults.get(key)
+                if default is not None:
+                    yield default
+                else:
+                    raise e
+
+    @classmethod
+    def from_list_dict(
+        cls, host, data, keys=None, headers=None, filters=None, defaults=None
+    ):
+        """Create a table from a list of dictionaries
+
+        each dictionary is a row of the table, keys being columns names.
+        the whole data will be read and kept into memory, to be printed
+        @param data(list[dict[unicode, unicode]]): data to create the table from
+        @param keys(iterable[unicode], None): keys to get
+            if None, all keys will be used
+        @param headers(iterable[unicode], None): name of the columns
+            names must be in same order as keys
+        @param filters(dict[unicode, (callable,unicode)), None): filter to use on values
+            keys correspond to keys to filter, and value is the same as for Table.__init__
+        @param defaults(dict[unicode, unicode]): default value to use
+            if None, an exception will be raised if not value is found
+        """
+        if keys is None and headers is not None:
+            # FIXME: keys are not needed with OrderedDict,
+            raise exceptions.DataError("You must specify keys order to used headers")
+        if keys is None:
+            keys = list(data[0].keys())
+        if headers is None:
+            headers = keys
+        if filters is None:
+            filters = {}
+        filters = [filters.get(k) for k in keys]
+        return cls(
+            host, (cls.read_dict_values(d, keys, defaults) for d in data), headers, filters
+        )
+
+    def _headers(self, head_sep, headers, sizes, alignment="left", style=None):
+        """Render headers
+
+        @param head_sep(unicode): sequence to use as separator
+        @param alignment(unicode): how to align, can be left, center or right
+        @param style(unicode, iterable[unicode], None): ANSI escape sequences to apply
+        @param headers(list[unicode]): headers to show
+        @param sizes(list[int]): sizes of columns
+        """
+        rendered_headers = []
+        if isinstance(style, str):
+            style = [style]
+        for idx, header in enumerate(headers):
+            size = sizes[idx]
+            if alignment == "left":
+                rendered = header[:size].ljust(size)
+            elif alignment == "center":
+                rendered = header[:size].center(size)
+            elif alignment == "right":
+                rendered = header[:size].rjust(size)
+            else:
+                raise exceptions.InternalError("bad alignment argument")
+            if style:
+                args = style + [rendered]
+                rendered = A.color(*args)
+            rendered_headers.append(rendered)
+        return head_sep.join(rendered_headers)
+
+    def _disp(self, data):
+        """output data (can be either bufferised or printed)"""
+        if self._buffer is not None:
+            self._buffer.append(data)
+        else:
+            self.host.disp(data)
+
+    def display(
+        self,
+        head_alignment="left",
+        columns_alignment="left",
+        head_style=None,
+        show_header=True,
+        show_borders=True,
+        hide_cols=None,
+        col_sep=" │ ",
+        top_left="┌",
+        top="─",
+        top_sep="─┬─",
+        top_right="┐",
+        left="│",
+        right=None,
+        head_sep=None,
+        head_line="┄",
+        head_line_left="├",
+        head_line_sep="┄┼┄",
+        head_line_right="┤",
+        bottom_left="└",
+        bottom=None,
+        bottom_sep="─┴─",
+        bottom_right="┘",
+    ):
+        """Print the table
+
+        @param show_header(bool): True if header need no be shown
+        @param show_borders(bool): True if borders need no be shown
+        @param hide_cols(None, iterable(unicode)): columns which should not be displayed
+        @param head_alignment(unicode): how to align headers, can be left, center or right
+        @param columns_alignment(unicode): how to align columns, can be left, center or
+            right
+        @param col_sep(unicode): separator betweens columns
+        @param head_line(unicode): character to use to make line under head
+        @param disp(callable, None): method to use to display the table
+            None to use self.host.disp
+        """
+        if not self.sizes:
+            # the table is empty
+            return
+        col_sep_size = len(regex.ansi_remove(col_sep))
+
+        # if we have columns to hide, we remove them from headers and size
+        if not hide_cols:
+            headers = self.headers
+            sizes = self.sizes
+        else:
+            headers = list(self.headers)
+            sizes = self.sizes[:]
+            ignore_idx = [headers.index(to_hide) for to_hide in hide_cols]
+            for to_hide in hide_cols:
+                hide_idx = headers.index(to_hide)
+                del headers[hide_idx]
+                del sizes[hide_idx]
+
+        if right is None:
+            right = left
+        if top_sep is None:
+            top_sep = col_sep_size * top
+        if head_sep is None:
+            head_sep = col_sep
+        if bottom is None:
+            bottom = top
+        if bottom_sep is None:
+            bottom_sep = col_sep_size * bottom
+        if not show_borders:
+            left = right = head_line_left = head_line_right = ""
+            # top border
+        if show_borders:
+            self._disp(
+                top_left + top_sep.join([top * size for size in sizes]) + top_right
+            )
+
+            # headers
+        if show_header and self.headers is not None:
+            self._disp(
+                left
+                + self._headers(head_sep, headers, sizes, head_alignment, head_style)
+                + right
+            )
+            # header line
+            self._disp(
+                head_line_left
+                + head_line_sep.join([head_line * size for size in sizes])
+                + head_line_right
+            )
+
+            # content
+        if columns_alignment == "left":
+            alignment = lambda idx, s: ansi_ljust(s, sizes[idx])
+        elif columns_alignment == "center":
+            alignment = lambda idx, s: ansi_center(s, sizes[idx])
+        elif columns_alignment == "right":
+            alignment = lambda idx, s: ansi_rjust(s, sizes[idx])
+        else:
+            raise exceptions.InternalError("bad columns alignment argument")
+
+        for row in self.rows:
+            if hide_cols:
+                row = [v for idx, v in enumerate(row) if idx not in ignore_idx]
+            self._disp(
+                left
+                + col_sep.join([alignment(idx, c) for idx, c in enumerate(row)])
+                + right
+            )
+
+        if show_borders:
+            # bottom border
+            self._disp(
+                bottom_left
+                + bottom_sep.join([bottom * size for size in sizes])
+                + bottom_right
+            )
+            #  we return self so string can be used after display (table.display().string)
+        return self
+
+    def display_blank(self, **kwargs):
+        """Display table without visible borders"""
+        kwargs_ = {"col_sep": " ", "head_line_sep": " ", "show_borders": False}
+        kwargs_.update(kwargs)
+        return self.display(**kwargs_)
+
+
+async def fill_well_known_uri(command, path, key, meta_map=None):
+    """Look for URIs in well-known location and fill appropriate args if suitable
+
+    @param command(CommandBase): command instance
+        args of this instance will be updated with found values
+    @param path(unicode): absolute path to use as a starting point to look for URIs
+    @param key(unicode): key to look for
+    @param meta_map(dict, None): if not None, map metadata to arg name
+        key is metadata used attribute name
+        value is name to actually use, or None to ignore
+        use empty dict to only retrieve URI
+        possible keys are currently:
+            - labels
+    """
+    args = command.args
+    if args.service or args.node:
+        # we only look for URIs if a service and a node are not already specified
+        return
+
+    host = command.host
+
+    try:
+        uris_data = await host.bridge.uri_find(path, [key])
+    except Exception as e:
+        host.disp(f"can't find {key} URI: {e}", error=True)
+        host.quit(C.EXIT_BRIDGE_ERRBACK)
+
+    try:
+        uri_data = uris_data[key]
+    except KeyError:
+        host.disp(
+            _(
+                "No {key} URI specified for this project, please specify service and "
+                "node"
+            ).format(key=key),
+            error=True,
+        )
+        host.quit(C.EXIT_NOT_FOUND)
+
+    uri = uri_data["uri"]
+
+    # set extra metadata if they are specified
+    for data_key in ["labels"]:
+        new_values_json = uri_data.get(data_key)
+        if uri_data is not None:
+            if meta_map is None:
+                dest = data_key
+            else:
+                dest = meta_map.get(data_key)
+                if dest is None:
+                    continue
+
+            try:
+                values = getattr(args, data_key)
+            except AttributeError:
+                raise exceptions.InternalError(f"there is no {data_key!r} arguments")
+            else:
+                if values is None:
+                    values = []
+                values.extend(json.loads(new_values_json))
+                setattr(args, dest, values)
+
+    parsed_uri = xmpp_uri.parse_xmpp_uri(uri)
+    try:
+        args.service = parsed_uri["path"]
+        args.node = parsed_uri["node"]
+    except KeyError:
+        host.disp(_("Invalid URI found: {uri}").format(uri=uri), error=True)
+        host.quit(C.EXIT_DATA_ERROR)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/constants.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.frontends.quick_frontend import constants
+from libervia.backend.tools.common.ansi import ANSI as A
+
+
+class Const(constants.Const):
+
+    APP_NAME = "Libervia CLI"
+    APP_COMPONENT = "CLI"
+    APP_NAME_ALT = "jp"
+    APP_NAME_FILE = "libervia_cli"
+    CONFIG_SECTION = APP_COMPONENT.lower()
+    PLUGIN_CMD = "commands"
+    PLUGIN_OUTPUT = "outputs"
+    OUTPUT_TEXT = "text"  # blob of unicode text
+    OUTPUT_DICT = "dict"  # simple key/value dictionary
+    OUTPUT_LIST = "list"
+    OUTPUT_LIST_DICT = "list_dict"  # list of dictionaries
+    OUTPUT_DICT_DICT = "dict_dict"  # dict  of nested dictionaries
+    OUTPUT_MESS = "mess"  # messages (chat)
+    OUTPUT_COMPLEX = "complex"  # complex data (e.g. multi-level dictionary)
+    OUTPUT_XML = "xml"  # XML node (as unicode string)
+    OUTPUT_LIST_XML = "list_xml"  # list of XML nodes (as unicode strings)
+    OUTPUT_XMLUI = "xmlui"  # XMLUI as unicode string
+    OUTPUT_LIST_XMLUI = "list_xmlui"  # list of XMLUI (as unicode strings)
+    OUTPUT_TYPES = (
+        OUTPUT_TEXT,
+        OUTPUT_DICT,
+        OUTPUT_LIST,
+        OUTPUT_LIST_DICT,
+        OUTPUT_DICT_DICT,
+        OUTPUT_MESS,
+        OUTPUT_COMPLEX,
+        OUTPUT_XML,
+        OUTPUT_LIST_XML,
+        OUTPUT_XMLUI,
+        OUTPUT_LIST_XMLUI,
+    )
+    OUTPUT_NAME_SIMPLE = "simple"
+    OUTPUT_NAME_XML = "xml"
+    OUTPUT_NAME_XML_RAW = "xml-raw"
+    OUTPUT_NAME_JSON = "json"
+    OUTPUT_NAME_JSON_RAW = "json-raw"
+
+    # Pubsub options flags
+    SERVICE = "service"  # service required
+    NODE = "node"  # node required
+    ITEM = "item"  # item required
+    SINGLE_ITEM = "single_item"  # only one item is allowed
+    MULTI_ITEMS = "multi_items"  # multiple items are allowed
+    NO_MAX = "no_max"  # don't add --max option for multi items
+    CACHE = "cache"  # add cache control flag
+
+    # ANSI
+    A_HEADER = A.BOLD + A.FG_YELLOW
+    A_SUBHEADER = A.BOLD + A.FG_RED
+    # A_LEVEL_COLORS may be used to cycle on colors according to depth of data
+    A_LEVEL_COLORS = (A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
+    A_SUCCESS = A.BOLD + A.FG_GREEN
+    A_FAILURE = A.BOLD + A.FG_RED
+    A_WARNING = A.BOLD + A.FG_RED
+    #  A_PROMPT_* is for shell
+    A_PROMPT_PATH = A.BOLD + A.FG_CYAN
+    A_PROMPT_SUF = A.BOLD
+    # Files
+    A_DIRECTORY = A.BOLD + A.FG_CYAN
+    A_FILE = A.FG_WHITE
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/loops.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+
+# jp: a SAT command line tool
+# Copyright (C) 2009-2021 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/>.
+
+import sys
+import asyncio
+import logging as log
+from libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+
+log.basicConfig(level=log.WARNING,
+                format='[%(name)s] %(message)s')
+
+USER_INTER_MSG = _("User interruption: good bye")
+
+
+class QuitException(BaseException):
+    """Quitting is requested
+
+    This is used to stop execution when host.quit() is called
+    """
+
+
+def get_jp_loop(bridge_name):
+    if 'dbus' in bridge_name:
+        import signal
+        import threading
+        from gi.repository import GLib
+
+        class JPLoop:
+
+            def run(self, jp, args, namespace):
+                signal.signal(signal.SIGINT, self._on_sigint)
+                self._glib_loop = GLib.MainLoop()
+                threading.Thread(target=self._glib_loop.run).start()
+                loop = asyncio.get_event_loop()
+                loop.run_until_complete(jp.main(args=args, namespace=namespace))
+                loop.run_forever()
+
+            def quit(self, exit_code):
+                loop = asyncio.get_event_loop()
+                loop.stop()
+                self._glib_loop.quit()
+                sys.exit(exit_code)
+
+            def call_later(self, delay, callback, *args):
+                """call a callback repeatedly
+
+                @param delay(int): delay between calls in s
+                @param callback(callable): method to call
+                    if the callback return True, the call will continue
+                    else the calls will stop
+                @param *args: args of the callbac
+                """
+                loop = asyncio.get_event_loop()
+                loop.call_later(delay, callback, *args)
+
+            def _on_sigint(self, sig_number, stack_frame):
+                """Called on keyboard interruption
+
+                Print user interruption message, set exit code and stop reactor
+                """
+                print("\r" + USER_INTER_MSG)
+                self.quit(C.EXIT_USER_CANCELLED)
+    else:
+        import signal
+        from twisted.internet import asyncioreactor
+        asyncioreactor.install()
+        from twisted.internet import reactor, defer
+
+        class JPLoop:
+
+            def __init__(self):
+                # exit code must be set when using quit, so if it's not set
+                # something got wrong and we must report it
+                self._exit_code = C.EXIT_INTERNAL_ERROR
+
+            def run(self, jp, *args):
+                self.jp = jp
+                signal.signal(signal.SIGINT, self._on_sigint)
+                defer.ensureDeferred(self._start(jp, *args))
+                try:
+                    reactor.run(installSignalHandlers=False)
+                except SystemExit as e:
+                    self._exit_code = e.code
+                sys.exit(self._exit_code)
+
+            async def _start(self, jp, *args):
+                fut = asyncio.ensure_future(jp.main(*args))
+                try:
+                    await defer.Deferred.fromFuture(fut)
+                except BaseException:
+                    import traceback
+                    traceback.print_exc()
+                    jp.quit(1)
+
+            def quit(self, exit_code):
+                self._exit_code = exit_code
+                reactor.stop()
+
+            def _timeout_cb(self, args, callback, delay):
+                try:
+                    ret = callback(*args)
+                # FIXME: temporary hack to avoid traceback when using XMLUI
+                #        to be removed once create_task is not used anymore in
+                #        xmlui_manager (i.e. once libervia.frontends.tools.xmlui fully supports
+                #        async syntax)
+                except QuitException:
+                    return
+                if ret:
+                    reactor.callLater(delay, self._timeout_cb, args, callback, delay)
+
+            def call_later(self, delay, callback, *args):
+                reactor.callLater(delay, self._timeout_cb, args, callback, delay)
+
+            def _on_sigint(self, sig_number, stack_frame):
+                """Called on keyboard interruption
+
+                Print user interruption message, set exit code and stop reactor
+                """
+                print("\r" + USER_INTER_MSG)
+                self._exit_code = C.EXIT_USER_CANCELLED
+                reactor.callFromThread(reactor.stop)
+
+
+    if bridge_name == "embedded":
+        raise NotImplementedError
+        # from sat.core import sat_main
+        # sat = sat_main.SAT()
+
+    return JPLoop
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/output_std.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,127 @@
+#! /usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+"""Standard outputs"""
+
+
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.tools import jid
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import date_utils
+import json
+
+__outputs__ = ["Simple", "Json"]
+
+
+class Simple(object):
+    """Default outputs"""
+
+    def __init__(self, host):
+        self.host = host
+        host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_SIMPLE, self.simple_print)
+        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_SIMPLE, self.list)
+        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict)
+        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_SIMPLE, self.list_dict)
+        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict_dict)
+        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_SIMPLE, self.messages)
+        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_SIMPLE, self.simple_print)
+
+    def simple_print(self, data):
+        self.host.disp(str(data))
+
+    def list(self, data):
+        self.host.disp("\n".join(data))
+
+    def dict(self, data, indent=0, header_color=C.A_HEADER):
+        options = self.host.parse_output_options()
+        self.host.check_output_options({"no-header"}, options)
+        show_header = not "no-header" in options
+        for k, v in data.items():
+            if show_header:
+                header = A.color(header_color, k) + ": "
+            else:
+                header = ""
+
+            self.host.disp(
+                (
+                    "{indent}{header}{value}".format(
+                        indent=indent * " ", header=header, value=v
+                    )
+                )
+            )
+
+    def list_dict(self, data):
+        for idx, datum in enumerate(data):
+            if idx:
+                self.host.disp("\n")
+            self.dict(datum)
+
+    def dict_dict(self, data):
+        for key, sub_dict in data.items():
+            self.host.disp(A.color(C.A_HEADER, key))
+            self.dict(sub_dict, indent=4, header_color=C.A_SUBHEADER)
+
+    def messages(self, data):
+        # TODO: handle lang, and non chat message (normal, headline)
+        for mess_data in data:
+            (uid, timestamp, from_jid, to_jid, message, subject, mess_type,
+             extra) = mess_data
+            time_str = date_utils.date_fmt(timestamp, "auto_day",
+                                           tz_info=date_utils.TZ_LOCAL)
+            from_jid = jid.JID(from_jid)
+            if mess_type == C.MESS_TYPE_GROUPCHAT:
+                nick = from_jid.resource
+            else:
+                nick = from_jid.node
+
+            if self.host.own_jid is not None and self.host.own_jid.bare == from_jid.bare:
+                nick_color = A.BOLD + A.FG_BLUE
+            else:
+                nick_color = A.BOLD + A.FG_YELLOW
+            message = list(message.values())[0] if message else ""
+
+            self.host.disp(A.color(
+                A.FG_CYAN, '['+time_str+'] ',
+                nick_color, nick, A.RESET, A.BOLD, '> ',
+                A.RESET, message))
+
+
+class Json(object):
+    """outputs in json format"""
+
+    def __init__(self, host):
+        self.host = host
+        host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_JSON, self.dump)
+        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON_RAW, self.dump)
+        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON, self.dump_pretty)
+        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON_RAW, self.dump)
+
+    def dump(self, data):
+        self.host.disp(json.dumps(data, default=str))
+
+    def dump_pretty(self, data):
+        self.host.disp(json.dumps(data, indent=4, default=str))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/output_template.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,138 @@
+#! /usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+"""Standard outputs"""
+
+
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log
+from libervia.backend.tools.common import template
+from functools import partial
+import logging
+import webbrowser
+import tempfile
+import os.path
+
+__outputs__ = ["Template"]
+TEMPLATE = "template"
+OPTIONS = {"template", "browser", "inline-css"}
+
+
+class Template(object):
+    """outputs data using SàT templates"""
+
+    def __init__(self, jp):
+        self.host = jp
+        jp.register_output(C.OUTPUT_COMPLEX, TEMPLATE, self.render)
+
+    def _front_url_tmp_dir(self, ctx, relative_url, tmp_dir):
+        """Get front URL for temporary directory"""
+        template_data = ctx['template_data']
+        return "file://" + os.path.join(tmp_dir, template_data.theme, relative_url)
+
+    def _do_render(self, template_path, css_inline, **kwargs):
+        try:
+            return self.renderer.render(template_path, css_inline=css_inline, **kwargs)
+        except template.TemplateNotFound:
+            self.host.disp(_("Can't find requested template: {template_path}")
+                .format(template_path=template_path), error=True)
+            self.host.quit(C.EXIT_NOT_FOUND)
+
+    def render(self, data):
+        """render output data using requested template
+
+        template to render the data can be either command's TEMPLATE or
+        template output_option requested by user.
+        @param data(dict): data is a dict which map from variable name to use in template
+            to the variable itself.
+            command's template_data_mapping attribute will be used if it exists to convert
+            data to a dict usable by the template.
+        """
+        # media_dir is needed for the template
+        self.host.media_dir = self.host.bridge.config_get("", "media_dir")
+        cmd = self.host.command
+        try:
+            template_path = cmd.TEMPLATE
+        except AttributeError:
+            if not "template" in cmd.args.output_opts:
+                self.host.disp(_(
+                    "no default template set for this command, you need to specify a "
+                    "template using --oo template=[path/to/template.html]"),
+                    error=True,
+                )
+                self.host.quit(C.EXIT_BAD_ARG)
+
+        options = self.host.parse_output_options()
+        self.host.check_output_options(OPTIONS, options)
+        try:
+            template_path = options["template"]
+        except KeyError:
+            # template is not specified, we use default one
+            pass
+        if template_path is None:
+            self.host.disp(_("Can't parse template, please check its syntax"),
+                           error=True)
+            self.host.quit(C.EXIT_BAD_ARG)
+
+        try:
+            mapping_cb = cmd.template_data_mapping
+        except AttributeError:
+            kwargs = data
+        else:
+            kwargs = mapping_cb(data)
+
+        css_inline = "inline-css" in options
+
+        if "browser" in options:
+            template_name = os.path.basename(template_path)
+            tmp_dir = tempfile.mkdtemp()
+            front_url_filter = partial(self._front_url_tmp_dir, tmp_dir=tmp_dir)
+            self.renderer = template.Renderer(
+                self.host, front_url_filter=front_url_filter, trusted=True)
+            rendered = self._do_render(template_path, css_inline=css_inline, **kwargs)
+            self.host.disp(_(
+                "Browser opening requested.\n"
+                "Temporary files are put in the following directory, you'll have to "
+                "delete it yourself once finished viewing: {}").format(tmp_dir))
+            tmp_file = os.path.join(tmp_dir, template_name)
+            with open(tmp_file, "w") as f:
+                f.write(rendered.encode("utf-8"))
+            theme, theme_root_path = self.renderer.get_theme_and_root(template_path)
+            if theme is None:
+                # we have an absolute path
+                webbrowser
+            static_dir = os.path.join(theme_root_path, C.TEMPLATE_STATIC_DIR)
+            if os.path.exists(static_dir):
+                # we have to copy static files in a subdirectory, to avoid file download
+                # to be blocked by same origin policy
+                import shutil
+                shutil.copytree(
+                    static_dir, os.path.join(tmp_dir, theme, C.TEMPLATE_STATIC_DIR)
+                )
+            webbrowser.open(tmp_file)
+        else:
+            # FIXME: Q&D way to disable template logging
+            #        logs are overcomplicated, and need to be reworked
+            template_logger = log.getLogger("sat.tools.common.template")
+            template_logger.log = lambda *args: None
+
+            logging.disable(logging.WARNING)
+            self.renderer = template.Renderer(self.host, trusted=True)
+            rendered = self._do_render(template_path, css_inline=css_inline, **kwargs)
+            self.host.disp(rendered)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/output_xml.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,90 @@
+#! /usr/bin/env python3
+
+# Libervia CLI frontend
+# Copyright (C) 2009-2021 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/>.
+"""Standard outputs"""
+
+
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.core.i18n import _
+from lxml import etree
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+import sys
+
+try:
+    import pygments
+    from pygments.lexers.html import XmlLexer
+    from pygments.formatters import TerminalFormatter
+except ImportError:
+    pygments = None
+
+
+__outputs__ = ["XML"]
+
+
+class XML(object):
+    """Outputs for XML"""
+
+    def __init__(self, host):
+        self.host = host
+        host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML, self.pretty, default=True)
+        host.register_output(
+            C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML, self.pretty_list, default=True
+        )
+        host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML_RAW, self.raw)
+        host.register_output(C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML_RAW, self.list_raw)
+
+    def colorize(self, xml):
+        if pygments is None:
+            self.host.disp(
+                _(
+                    "Pygments is not available, syntax highlighting is not possible. "
+                    "Please install if from http://pygments.org or with pip install "
+                    "pygments"
+                ),
+                error=True,
+            )
+            return xml
+        if not sys.stdout.isatty():
+            return xml
+        lexer = XmlLexer(encoding="utf-8")
+        formatter = TerminalFormatter(bg="dark")
+        return pygments.highlight(xml, lexer, formatter)
+
+    def format(self, data, pretty=True):
+        parser = etree.XMLParser(remove_blank_text=True)
+        tree = etree.fromstring(data, parser)
+        xml = etree.tostring(tree, encoding="unicode", pretty_print=pretty)
+        return self.colorize(xml)
+
+    def format_no_pretty(self, data):
+        return self.format(data, pretty=False)
+
+    def pretty(self, data):
+        self.host.disp(self.format(data))
+
+    def pretty_list(self, data, separator="\n"):
+        list_pretty = list(map(self.format, data))
+        self.host.disp(separator.join(list_pretty))
+
+    def raw(self, data):
+        self.host.disp(self.format_no_pretty(data))
+
+    def list_raw(self, data, separator="\n"):
+        list_no_pretty = list(map(self.format_no_pretty, data))
+        self.host.disp(separator.join(list_no_pretty))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/output_xmlui.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,49 @@
+#! /usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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/>.
+"""Standard outputs"""
+
+
+from libervia.frontends.jp.constants import Const as C
+from libervia.frontends.jp import xmlui_manager
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+__outputs__ = ["XMLUI"]
+
+
+class XMLUI(object):
+    """Outputs for XMLUI"""
+
+    def __init__(self, host):
+        self.host = host
+        host.register_output(C.OUTPUT_XMLUI, "simple", self.xmlui, default=True)
+        host.register_output(
+            C.OUTPUT_LIST_XMLUI, "simple", self.xmlui_list, default=True
+        )
+
+    async def xmlui(self, data):
+        xmlui = xmlui_manager.create(self.host, data)
+        await xmlui.show(values_only=True, read_only=True)
+        self.host.disp("")
+
+    async def xmlui_list(self, data):
+        for d in data:
+            await self.xmlui(d)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/xml_tools.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+
+
+# jp: a SàT command line tool
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+from libervia.frontends.jp.constants import Const as C
+
+def etree_parse(cmd, raw_xml, reraise=False):
+    """import lxml and parse raw XML
+
+    @param cmd(CommandBase): current command instance
+    @param raw_xml(file, str): an XML bytestring, string or file-like object
+    @param reraise(bool): if True, re raise exception on parse error instead of doing a
+        parser.error (which terminate the execution)
+    @return (tuple(etree.Element, module): parsed element, etree module
+    """
+    try:
+        from lxml import etree
+    except ImportError:
+        cmd.disp(
+            'lxml module must be installed, please install it with "pip install lxml"',
+            error=True,
+        )
+        cmd.host.quit(C.EXIT_ERROR)
+    try:
+        if isinstance(raw_xml, str):
+            parser = etree.XMLParser(remove_blank_text=True)
+            element = etree.fromstring(raw_xml, parser)
+        else:
+            element = etree.parse(raw_xml).getroot()
+    except Exception as e:
+        if reraise:
+            raise e
+        cmd.parser.error(
+            _("Can't parse the payload XML in input: {msg}").format(msg=e)
+        )
+    return element, etree
+
+def get_payload(cmd, element):
+    """Retrieve payload element and exit with and error if not found
+
+    @param element(etree.Element): root element
+    @return element(etree.Element): payload element
+    """
+    if element.tag in ("item", "{http://jabber.org/protocol/pubsub}item"):
+        if len(element) > 1:
+            cmd.disp(_("<item> can only have one child element (the payload)"),
+                     error=True)
+            cmd.host.quit(C.EXIT_DATA_ERROR)
+        element = element[0]
+    return element
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/jp/xmlui_manager.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,652 @@
+#!/usr/bin/env python3
+
+
+# JP: a SàT frontend
+# Copyright (C) 2009-2021 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 functools import partial
+from libervia.backend.core.log import getLogger
+from libervia.frontends.tools import xmlui as xmlui_base
+from libervia.frontends.jp.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+# workflow constants
+
+SUBMIT = "SUBMIT"  # submit form
+
+
+## Widgets ##
+
+
+class Base(object):
+    """Base for Widget and Container"""
+
+    type = None
+    _root = None
+
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        self.host = self.xmlui_parent.host
+
+    @property
+    def root(self):
+        """retrieve main XMLUI parent class"""
+        if self._root is not None:
+            return self._root
+        root = self
+        while not isinstance(root, xmlui_base.XMLUIBase):
+            root = root.xmlui_parent
+        self._root = root
+        return root
+
+    def disp(self, *args, **kwargs):
+        self.host.disp(*args, **kwargs)
+
+
+class Widget(Base):
+    category = "widget"
+    enabled = True
+
+    @property
+    def name(self):
+        return self._xmlui_name
+
+    async def show(self):
+        """display current widget
+
+        must be overriden by subclasses
+        """
+        raise NotImplementedError(self.__class__)
+
+    def verbose_name(self, elems=None, value=None):
+        """add name in color to the elements
+
+        helper method to display name which can then be used to automate commands
+        elems is only modified if verbosity is > 0
+        @param elems(list[unicode], None): elements to display
+            None to display name directly
+        @param value(unicode, None): value to show
+            use self.name if None
+        """
+        if value is None:
+            value = self.name
+        if self.host.verbosity:
+            to_disp = [
+                A.FG_MAGENTA,
+                " " if elems else "",
+                "({})".format(value),
+                A.RESET,
+            ]
+            if elems is None:
+                self.host.disp(A.color(*to_disp))
+            else:
+                elems.extend(to_disp)
+
+
+class ValueWidget(Widget):
+    def __init__(self, xmlui_parent, value):
+        super(ValueWidget, self).__init__(xmlui_parent)
+        self.value = value
+
+    @property
+    def values(self):
+        return [self.value]
+
+
+class InputWidget(ValueWidget):
+    def __init__(self, xmlui_parent, value, read_only=False):
+        super(InputWidget, self).__init__(xmlui_parent, value)
+        self.read_only = read_only
+
+    def _xmlui_get_value(self):
+        return self.value
+
+
+class OptionsWidget(Widget):
+    def __init__(self, xmlui_parent, options, selected, style):
+        super(OptionsWidget, self).__init__(xmlui_parent)
+        self.options = options
+        self.selected = selected
+        self.style = style
+
+    @property
+    def values(self):
+        return self.selected
+
+    @values.setter
+    def values(self, values):
+        self.selected = values
+
+    @property
+    def value(self):
+        return self.selected[0]
+
+    @value.setter
+    def value(self, value):
+        self.selected = [value]
+
+    def _xmlui_select_value(self, value):
+        self.value = value
+
+    def _xmlui_select_values(self, values):
+        self.values = values
+
+    def _xmlui_get_selected_values(self):
+        return self.values
+
+    @property
+    def labels(self):
+        """return only labels from self.items"""
+        for value, label in self.items:
+            yield label
+
+    @property
+    def items(self):
+        """return suitable items, according to style"""
+        no_select = self.no_select
+        for value, label in self.options:
+            if no_select or value in self.selected:
+                yield value, label
+
+    @property
+    def inline(self):
+        return "inline" in self.style
+
+    @property
+    def no_select(self):
+        return "noselect" in self.style
+
+
+class EmptyWidget(xmlui_base.EmptyWidget, Widget):
+    def __init__(self, xmlui_parent):
+        Widget.__init__(self, xmlui_parent)
+
+    async def show(self):
+        self.host.disp("")
+
+
+class TextWidget(xmlui_base.TextWidget, ValueWidget):
+    type = "text"
+
+    async def show(self):
+        self.host.disp(self.value)
+
+
+class LabelWidget(xmlui_base.LabelWidget, ValueWidget):
+    type = "label"
+
+    @property
+    def for_name(self):
+        try:
+            return self._xmlui_for_name
+        except AttributeError:
+            return None
+
+    async def show(self, end="\n", ansi=""):
+        """show label
+
+        @param end(str): same as for [JP.disp]
+        @param ansi(unicode): ansi escape code to print before label
+        """
+        self.disp(A.color(ansi, self.value), end=end)
+
+
+class JidWidget(xmlui_base.JidWidget, TextWidget):
+    type = "jid"
+
+
+class StringWidget(xmlui_base.StringWidget, InputWidget):
+    type = "string"
+
+    async def show(self):
+        if self.read_only or self.root.read_only:
+            self.disp(self.value)
+        else:
+            elems = []
+            self.verbose_name(elems)
+            if self.value:
+                elems.append(_("(enter: {value})").format(value=self.value))
+            elems.extend([C.A_HEADER, "> "])
+            value = await self.host.ainput(A.color(*elems))
+            if value:
+                #  TODO: empty value should be possible
+                #       an escape key should be used for default instead of enter with empty value
+                self.value = value
+
+
+class JidInputWidget(xmlui_base.JidInputWidget, StringWidget):
+    type = "jid_input"
+
+
+class PasswordWidget(xmlui_base.PasswordWidget, StringWidget):
+    type = "password"
+
+
+class TextBoxWidget(xmlui_base.TextWidget, StringWidget):
+    type = "textbox"
+    # TODO: use a more advanced input method
+
+    async def show(self):
+        self.verbose_name()
+        if self.read_only or self.root.read_only:
+            self.disp(self.value)
+        else:
+            if self.value:
+                self.disp(
+                    A.color(C.A_HEADER, "↓ current value ↓\n", A.FG_CYAN, self.value, "")
+                )
+
+            values = []
+            while True:
+                try:
+                    if not values:
+                        line = await self.host.ainput(
+                            A.color(C.A_HEADER, "[Ctrl-D to finish]> ")
+                        )
+                    else:
+                        line = await self.host.ainput()
+                    values.append(line)
+                except EOFError:
+                    break
+
+            self.value = "\n".join(values).rstrip()
+
+
+class XHTMLBoxWidget(xmlui_base.XHTMLBoxWidget, StringWidget):
+    type = "xhtmlbox"
+
+    async def show(self):
+        # FIXME: we use bridge in a blocking way as permitted by python-dbus
+        #        this only for now to make it simpler, it must be refactored
+        #        to use async when jp will be fully async (expected for 0.8)
+        self.value = await self.host.bridge.syntax_convert(
+            self.value, C.SYNTAX_XHTML, "markdown", False, self.host.profile
+        )
+        await super(XHTMLBoxWidget, self).show()
+
+
+class ListWidget(xmlui_base.ListWidget, OptionsWidget):
+    type = "list"
+    # TODO: handle flags, notably multi
+
+    async def show(self):
+        if self.root.values_only:
+            for value in self.values:
+                self.disp(self.value)
+                return
+        if not self.options:
+            return
+
+            # list display
+        self.verbose_name()
+
+        for idx, (value, label) in enumerate(self.options):
+            elems = []
+            if not self.root.read_only:
+                elems.extend([C.A_SUBHEADER, str(idx), A.RESET, ": "])
+            elems.append(label)
+            self.verbose_name(elems, value)
+            self.disp(A.color(*elems))
+
+        if self.root.read_only:
+            return
+
+        if len(self.options) == 1:
+            # we have only one option, no need to ask
+            self.value = self.options[0][0]
+            return
+
+            #  we ask use to choose an option
+        choice = None
+        limit_max = len(self.options) - 1
+        while choice is None or choice < 0 or choice > limit_max:
+            choice = await self.host.ainput(
+                A.color(
+                    C.A_HEADER,
+                    _("your choice (0-{limit_max}): ").format(limit_max=limit_max),
+                )
+            )
+            try:
+                choice = int(choice)
+            except ValueError:
+                choice = None
+        self.value = self.options[choice][0]
+        self.disp("")
+
+
+class BoolWidget(xmlui_base.BoolWidget, InputWidget):
+    type = "bool"
+
+    async def show(self):
+        disp_true = A.color(A.FG_GREEN, "TRUE")
+        disp_false = A.color(A.FG_RED, "FALSE")
+        if self.read_only or self.root.read_only:
+            self.disp(disp_true if self.value else disp_false)
+        else:
+            self.disp(
+                A.color(
+                    C.A_HEADER, "0: ", disp_false, A.RESET, " *" if not self.value else ""
+                )
+            )
+            self.disp(
+                A.color(C.A_HEADER, "1: ", disp_true, A.RESET, " *" if self.value else "")
+            )
+            choice = None
+            while choice not in ("0", "1"):
+                elems = [C.A_HEADER, _("your choice (0,1): ")]
+                self.verbose_name(elems)
+                choice = await self.host.ainput(A.color(*elems))
+            self.value = bool(int(choice))
+            self.disp("")
+
+    def _xmlui_get_value(self):
+        return C.bool_const(self.value)
+
+        ## Containers ##
+
+
+class Container(Base):
+    category = "container"
+
+    def __init__(self, xmlui_parent):
+        super(Container, self).__init__(xmlui_parent)
+        self.children = []
+
+    def __iter__(self):
+        return iter(self.children)
+
+    def _xmlui_append(self, widget):
+        self.children.append(widget)
+
+    def _xmlui_remove(self, widget):
+        self.children.remove(widget)
+
+    async def show(self):
+        for child in self.children:
+            await child.show()
+
+
+class VerticalContainer(xmlui_base.VerticalContainer, Container):
+    type = "vertical"
+
+
+class PairsContainer(xmlui_base.PairsContainer, Container):
+    type = "pairs"
+
+
+class LabelContainer(xmlui_base.PairsContainer, Container):
+    type = "label"
+
+    async def show(self):
+        for child in self.children:
+            end = "\n"
+            # we check linked widget type
+            # to see if we want the label on the same line or not
+            if child.type == "label":
+                for_name = child.for_name
+                if for_name:
+                    for_widget = self.root.widgets[for_name]
+                    wid_type = for_widget.type
+                    if self.root.values_only or wid_type in (
+                        "text",
+                        "string",
+                        "jid_input",
+                    ):
+                        end = " "
+                    elif wid_type == "bool" and for_widget.read_only:
+                        end = " "
+                await child.show(end=end, ansi=A.FG_CYAN)
+            else:
+                await child.show()
+
+                ## Dialogs ##
+
+
+class Dialog(object):
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        self.host = self.xmlui_parent.host
+
+    def disp(self, *args, **kwargs):
+        self.host.disp(*args, **kwargs)
+
+    async def show(self):
+        """display current dialog
+
+        must be overriden by subclasses
+        """
+        raise NotImplementedError(self.__class__)
+
+
+class MessageDialog(xmlui_base.MessageDialog, Dialog):
+    def __init__(self, xmlui_parent, title, message, level):
+        Dialog.__init__(self, xmlui_parent)
+        xmlui_base.MessageDialog.__init__(self, xmlui_parent)
+        self.title, self.message, self.level = title, message, level
+
+    async def show(self):
+        # TODO: handle level
+        if self.title:
+            self.disp(A.color(C.A_HEADER, self.title))
+        self.disp(self.message)
+
+
+class NoteDialog(xmlui_base.NoteDialog, Dialog):
+    def __init__(self, xmlui_parent, title, message, level):
+        Dialog.__init__(self, xmlui_parent)
+        xmlui_base.NoteDialog.__init__(self, xmlui_parent)
+        self.title, self.message, self.level = title, message, level
+
+    async def show(self):
+        # TODO: handle title
+        error = self.level in (C.XMLUI_DATA_LVL_WARNING, C.XMLUI_DATA_LVL_ERROR)
+        if self.level == C.XMLUI_DATA_LVL_WARNING:
+            msg = A.color(C.A_WARNING, self.message)
+        elif self.level == C.XMLUI_DATA_LVL_ERROR:
+            msg = A.color(C.A_FAILURE, self.message)
+        else:
+            msg = self.message
+        self.disp(msg, error=error)
+
+
+class ConfirmDialog(xmlui_base.ConfirmDialog, Dialog):
+    def __init__(self, xmlui_parent, title, message, level, buttons_set):
+        Dialog.__init__(self, xmlui_parent)
+        xmlui_base.ConfirmDialog.__init__(self, xmlui_parent)
+        self.title, self.message, self.level, self.buttons_set = (
+            title,
+            message,
+            level,
+            buttons_set,
+        )
+
+    async def show(self):
+        # TODO: handle buttons_set and level
+        self.disp(self.message)
+        if self.title:
+            self.disp(A.color(C.A_HEADER, self.title))
+        input_ = None
+        while input_ not in ("y", "n"):
+            input_ = await self.host.ainput(f"{self.message} (y/n)? ")
+            input_ = input_.lower()
+        if input_ == "y":
+            self._xmlui_validated()
+        else:
+            self._xmlui_cancelled()
+
+            ## Factory ##
+
+
+class WidgetFactory(object):
+    def __getattr__(self, attr):
+        if attr.startswith("create"):
+            cls = globals()[attr[6:]]
+            return cls
+
+
+class XMLUIPanel(xmlui_base.AIOXMLUIPanel):
+    widget_factory = WidgetFactory()
+    _actions = 0  # use to keep track of bridge's action_launch calls
+    read_only = False
+    values_only = False
+    workflow = None
+    _submit_cb = None
+
+    def __init__(
+        self,
+        host,
+        parsed_dom,
+        title=None,
+        flags=None,
+        callback=None,
+        ignore=None,
+        whitelist=None,
+        profile=None,
+    ):
+        xmlui_base.XMLUIPanel.__init__(
+            self,
+            host,
+            parsed_dom,
+            title=title,
+            flags=flags,
+            ignore=ignore,
+            whitelist=whitelist,
+            profile=host.profile,
+        )
+        self.submitted = False
+
+    @property
+    def command(self):
+        return self.host.command
+
+    def disp(self, *args, **kwargs):
+        self.host.disp(*args, **kwargs)
+
+    async def show(self, workflow=None, read_only=False, values_only=False):
+        """display the panel
+
+        @param workflow(list, None): command to execute if not None
+            put here for convenience, the main workflow is the class attribute
+            (because workflow can continue in subclasses)
+            command are a list of consts or lists:
+                - SUBMIT is the only constant so far, it submits the XMLUI
+                - list must contain widget name/widget value to fill
+        @param read_only(bool): if True, don't request values
+        @param values_only(bool): if True, only show select values (imply read_only)
+        """
+        self.read_only = read_only
+        self.values_only = values_only
+        if self.values_only:
+            self.read_only = True
+        if workflow:
+            XMLUIPanel.workflow = workflow
+        if XMLUIPanel.workflow:
+            await self.run_workflow()
+        else:
+            await self.main_cont.show()
+
+    async def run_workflow(self):
+        """loop into workflow commands and execute commands
+
+        SUBMIT will interrupt workflow (which will be continue on callback)
+        @param workflow(list): same as [show]
+        """
+        workflow = XMLUIPanel.workflow
+        while True:
+            try:
+                cmd = workflow.pop(0)
+            except IndexError:
+                break
+            if cmd == SUBMIT:
+                await self.on_form_submitted()
+                self.submit_id = None  # avoid double submit
+                return
+            elif isinstance(cmd, list):
+                name, value = cmd
+                widget = self.widgets[name]
+                if widget.type == "bool":
+                    value = C.bool(value)
+                widget.value = value
+        await self.show()
+
+    async def submit_form(self, callback=None):
+        XMLUIPanel._submit_cb = callback
+        await self.on_form_submitted()
+
+    async def on_form_submitted(self, ignore=None):
+        # self.submitted is a Q&D workaround to avoid
+        # double submit when a workflow is set
+        if self.submitted:
+            return
+        self.submitted = True
+        await super(XMLUIPanel, self).on_form_submitted(ignore)
+
+    def _xmlui_close(self):
+        pass
+
+    async def _launch_action_cb(self, data):
+        XMLUIPanel._actions -= 1
+        assert XMLUIPanel._actions >= 0
+        if "xmlui" in data:
+            xmlui_raw = data["xmlui"]
+            xmlui = create(self.host, xmlui_raw)
+            await xmlui.show()
+            if xmlui.submit_id:
+                await xmlui.on_form_submitted()
+                # TODO: handle data other than XMLUI
+        if not XMLUIPanel._actions:
+            if self._submit_cb is None:
+                self.host.quit()
+            else:
+                self._submit_cb()
+
+    async def _xmlui_launch_action(self, action_id, data):
+        XMLUIPanel._actions += 1
+        try:
+            data = data_format.deserialise(
+                await self.host.bridge.action_launch(
+                    action_id,
+                    data_format.serialise(data),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't launch XMLUI action: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self._launch_action_cb(data)
+
+
+class XMLUIDialog(xmlui_base.XMLUIDialog):
+    type = "dialog"
+    dialog_factory = WidgetFactory()
+    read_only = False
+
+    async def show(self, __=None):
+        await self.dlg.show()
+
+    def _xmlui_close(self):
+        pass
+
+
+create = partial(
+    xmlui_base.create,
+    class_map={xmlui_base.CLASS_PANEL: XMLUIPanel, xmlui_base.CLASS_DIALOG: XMLUIDialog},
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/base.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,863 @@
+#!/usr/bin/env python3
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-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 libervia.backend.core.i18n import _, D_
+from libervia.frontends.primitivus.constants import Const as C
+from libervia.backend.core import log_config
+log_config.sat_configure(C.LOG_BACKEND_STANDARD, C)
+from libervia.backend.core import log as logging
+log = logging.getLogger(__name__)
+from libervia.backend.tools import config as sat_config
+import urwid
+from urwid.util import is_wide_char
+from urwid_satext import sat_widgets
+from libervia.frontends.quick_frontend.quick_app import QuickApp
+from libervia.frontends.quick_frontend import quick_utils
+from libervia.frontends.quick_frontend import quick_chat
+from libervia.frontends.primitivus.profile_manager import ProfileManager
+from libervia.frontends.primitivus.contact_list import ContactList
+from libervia.frontends.primitivus.chat import Chat
+from libervia.frontends.primitivus import xmlui
+from libervia.frontends.primitivus.progress import Progress
+from libervia.frontends.primitivus.notify import Notify
+from libervia.frontends.primitivus.keys import action_key_map as a_key
+from libervia.frontends.primitivus import config
+from libervia.frontends.tools.misc import InputHistory
+from libervia.backend.tools.common import dynamic_import
+from libervia.frontends.tools import jid
+import signal
+import sys
+## bridge handling
+# we get bridge name from conf and initialise the right class accordingly
+main_config = sat_config.parse_main_conf()
+bridge_name = sat_config.config_get(main_config, '', 'bridge', 'dbus')
+if 'dbus' not in bridge_name:
+    print(u"only D-Bus bridge is currently supported")
+    sys.exit(3)
+
+
+class EditBar(sat_widgets.ModalEdit):
+    """
+    The modal edit bar where you would enter messages and commands.
+    """
+
+    def __init__(self, host):
+        modes = {None: (C.MODE_NORMAL, u''),
+                 a_key['MODE_INSERTION']: (C.MODE_INSERTION, u'> '),
+                 a_key['MODE_COMMAND']: (C.MODE_COMMAND, u':')} #XXX: captions *MUST* be unicode
+        super(EditBar, self).__init__(modes)
+        self.host = host
+        self.set_completion_method(self._text_completion)
+        urwid.connect_signal(self, 'click', self.on_text_entered)
+
+    def _text_completion(self, text, completion_data, mode):
+        if mode == C.MODE_INSERTION:
+            if self.host.selected_widget is not None:
+                try:
+                    completion = self.host.selected_widget.completion
+                except AttributeError:
+                    return text
+                else:
+                    return completion(text, completion_data)
+        else:
+            return text
+
+    def on_text_entered(self, editBar):
+        """Called when text is entered in the main edit bar"""
+        if self.mode == C.MODE_INSERTION:
+            if isinstance(self.host.selected_widget, quick_chat.QuickChat):
+                chat_widget = self.host.selected_widget
+                self.host.message_send(
+                    chat_widget.target,
+                    {'': editBar.get_edit_text()}, # TODO: handle language
+                    mess_type = C.MESS_TYPE_GROUPCHAT if chat_widget.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, # TODO: put this in QuickChat
+                    errback=lambda failure: self.host.show_dialog(_("Error while sending message ({})").format(failure), type="error"),
+                    profile_key=chat_widget.profile
+                    )
+                editBar.set_edit_text('')
+        elif self.mode == C.MODE_COMMAND:
+            self.command_handler()
+
+    def command_handler(self):
+        #TODO: separate class with auto documentation (with introspection)
+        #      and completion method
+        tokens = self.get_edit_text().split(' ')
+        command, args = tokens[0], tokens[1:]
+        if command == 'quit':
+            self.host.on_exit()
+            raise urwid.ExitMainLoop()
+        elif command == 'messages':
+            wid = sat_widgets.GenericList(logging.memory_get())
+            self.host.select_widget(wid)
+        # FIXME: reactivate the command
+        # elif command == 'presence':
+        #     values = [value for value in commonConst.PRESENCE.keys()]
+        #     values = [value if value else 'online' for value in values]  # the empty value actually means 'online'
+        #     if args and args[0] in values:
+        #         presence = '' if args[0] == 'online' else args[0]
+        #         self.host.status_bar.on_change(user_data=sat_widgets.ClickableText(commonConst.PRESENCE[presence]))
+        #     else:
+        #         self.host.status_bar.on_presence_click()
+        # elif command == 'status':
+        #     if args:
+        #         self.host.status_bar.on_change(user_data=sat_widgets.AdvancedEdit(args[0]))
+        #     else:
+        #         self.host.status_bar.on_status_click()
+        elif command == 'history':
+            widget = self.host.selected_widget
+            if isinstance(widget, quick_chat.QuickChat):
+                try:
+                    limit = int(args[0])
+                except (IndexError, ValueError):
+                    limit = 50
+                widget.update_history(size=limit, profile=widget.profile)
+        elif command == 'search':
+            widget = self.host.selected_widget
+            if isinstance(widget, quick_chat.QuickChat):
+                pattern = " ".join(args)
+                if not pattern:
+                    self.host.notif_bar.add_message(D_("Please specify the globbing pattern to search for"))
+                else:
+                    widget.update_history(size=C.HISTORY_LIMIT_NONE, filters={'search': pattern}, profile=widget.profile)
+        elif command == 'filter':
+            # FIXME: filter is now only for current widget,
+            #        need to be able to set it globally or per widget
+            widget = self.host.selected_widget
+            # FIXME: Q&D way, need to be more generic
+            if isinstance(widget, quick_chat.QuickChat):
+                widget.set_filter(args)
+        elif command in ('topic', 'suject', 'title'):
+            try:
+                new_title = args[0].strip()
+            except IndexError:
+                new_title = None
+            widget = self.host.selected_widget
+            if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP:
+                widget.on_subject_dialog(new_title)
+        else:
+            return
+        self.set_edit_text('')
+
+    def _history_cb(self, text):
+        self.set_edit_text(text)
+        self.set_edit_pos(len(text))
+
+    def keypress(self, size, key):
+        """Callback when a key is pressed. Send "composing" states
+        and move the index of the temporary history stack."""
+        if key == a_key['MODAL_ESCAPE']:
+            # first save the text to the current mode, then change to NORMAL
+            self.host._update_input_history(self.get_edit_text(), mode=self.mode)
+            self.host._update_input_history(mode=C.MODE_NORMAL)
+        if self._mode == C.MODE_NORMAL and key in self._modes:
+            self.host._update_input_history(mode=self._modes[key][0])
+        if key == a_key['HISTORY_PREV']:
+            self.host._update_input_history(self.get_edit_text(), -1, self._history_cb, self.mode)
+            return
+        elif key == a_key['HISTORY_NEXT']:
+            self.host._update_input_history(self.get_edit_text(), +1, self._history_cb, self.mode)
+            return
+        elif key == a_key['EDIT_ENTER']:
+            self.host._update_input_history(self.get_edit_text(), mode=self.mode)
+        else:
+            if (self._mode == C.MODE_INSERTION
+                and isinstance(self.host.selected_widget, quick_chat.QuickChat)
+                and key not in sat_widgets.FOCUS_KEYS
+                and key not in (a_key['HISTORY_PREV'], a_key['HISTORY_NEXT'])
+                and self.host.sync):
+                self.host.bridge.chat_state_composing(self.host.selected_widget.target, self.host.selected_widget.profile)
+
+        return super(EditBar, self).keypress(size, key)
+
+
+class PrimitivusTopWidget(sat_widgets.FocusPile):
+    """Top most widget used in Primitivus"""
+    _focus_inversed = True
+    positions = ('menu', 'body', 'notif_bar', 'edit_bar')
+    can_hide = ('menu', 'notif_bar')
+
+    def __init__(self, body, menu, notif_bar, edit_bar):
+        self._body = body
+        self._menu = menu
+        self._notif_bar = notif_bar
+        self._edit_bar = edit_bar
+        self._hidden = {'notif_bar'}
+        self._focus_extra = False
+        super(PrimitivusTopWidget, self).__init__([('pack', self._menu), self._body, ('pack', self._edit_bar)])
+        for position in self.positions:
+            setattr(self,
+                    position,
+                    property(lambda: self, self.widget_get(position=position),
+                             lambda pos, new_wid: self.widget_set(new_wid, position=pos))
+                   )
+        self.focus_position = len(self.contents)-1
+
+    def get_visible_positions(self, keep=None):
+        """Return positions that are not hidden in the right order
+
+        @param keep: if not None, this position will be keep in the right order, even if it's hidden
+                    (can be useful to find its index)
+        @return (list): list of visible positions
+        """
+        return [pos for pos in self.positions if (keep and pos == keep) or pos not in self._hidden]
+
+    def keypress(self, size, key):
+        """Manage FOCUS keys that focus directly a main part (one of self.positions)
+
+        To avoid key conflicts, a combinaison must be made with FOCUS_EXTRA then an other key
+        """
+        if key == a_key['FOCUS_EXTRA']:
+            self._focus_extra = True
+            return
+        if self._focus_extra:
+            self._focus_extra = False
+            if key in ('m', '1'):
+                focus = 'menu'
+            elif key in ('b', '2'):
+                focus = 'body'
+            elif key in ('n', '3'):
+                focus = 'notif_bar'
+            elif key in ('e', '4'):
+                focus = 'edit_bar'
+            else:
+                return super(PrimitivusTopWidget, self).keypress(size, key)
+
+            if focus in self._hidden:
+                return
+
+            self.focus_position = self.get_visible_positions().index(focus)
+            return
+
+        return super(PrimitivusTopWidget, self).keypress(size, key)
+
+    def widget_get(self,  position):
+        if not position in self.positions:
+            raise ValueError("Unknown position {}".format(position))
+        return getattr(self, "_{}".format(position))
+
+    def widget_set(self,  widget, position):
+        if not position in self.positions:
+            raise ValueError("Unknown position {}".format(position))
+        return setattr(self, "_{}".format(position), widget)
+
+    def hide_switch(self, position):
+        if not position in self.can_hide:
+            raise ValueError("Can't switch position {}".format(position))
+        hide = not position in self._hidden
+        widget = self.widget_get(position)
+        idx = self.get_visible_positions(position).index(position)
+        if hide:
+            del self.contents[idx]
+            self._hidden.add(position)
+        else:
+            self.contents.insert(idx, (widget, ('pack', None)))
+            self._hidden.remove(position)
+
+    def show(self, position):
+        if position in self._hidden:
+            self.hide_switch(position)
+
+    def hide(self, position):
+        if not position in self._hidden:
+            self.hide_switch(position)
+
+
+class PrimitivusApp(QuickApp, InputHistory):
+    MB_HANDLER = False
+    AVATARS_HANDLER = False
+
+    def __init__(self):
+        bridge_module = dynamic_import.bridge(bridge_name, 'libervia.frontends.bridge')
+        if bridge_module is None:
+            log.error(u"Can't import {} bridge".format(bridge_name))
+            sys.exit(3)
+        else:
+            log.debug(u"Loading {} bridge".format(bridge_name))
+        QuickApp.__init__(self, bridge_factory=bridge_module.bridge, xmlui=xmlui, check_options=quick_utils.check_options, connect_bridge=False)
+        ## main loop setup ##
+        event_loop = urwid.GLibEventLoop if 'dbus' in bridge_name else urwid.TwistedEventLoop
+        self.loop = urwid.MainLoop(urwid.SolidFill(), C.PALETTE, event_loop=event_loop(), input_filter=self.input_filter, unhandled_input=self.key_handler)
+
+    @classmethod
+    def run(cls):
+        cls().start()
+
+    def on_bridge_connected(self):
+
+        ##misc setup##
+        self._visible_widgets = set()
+        self.notif_bar = sat_widgets.NotificationBar()
+        urwid.connect_signal(self.notif_bar, 'change', self.on_notification)
+
+        self.progress_wid = self.widgets.get_or_create_widget(Progress, None, on_new_widget=None)
+        urwid.connect_signal(self.notif_bar.progress, 'click', lambda x: self.select_widget(self.progress_wid))
+        self.__saved_overlay = None
+
+        self.x_notify = Notify()
+
+        # we already manage exit with a_key['APP_QUIT'], so we don't want C-c
+        signal.signal(signal.SIGINT, signal.SIG_IGN)
+        sat_conf = sat_config.parse_main_conf()
+        self._bracketed_paste = C.bool(
+            sat_config.config_get(sat_conf, C.CONFIG_SECTION, 'bracketed_paste', 'false')
+        )
+        if self._bracketed_paste:
+            log.debug("setting bracketed paste mode as requested")
+            sys.stdout.write("\033[?2004h")
+            self._bracketed_mode_set = True
+
+        self.loop.widget = self.main_widget = ProfileManager(self)
+        self.post_init()
+
+    @property
+    def visible_widgets(self):
+        return self._visible_widgets
+
+    @property
+    def mode(self):
+        return self.editBar.mode
+
+    @mode.setter
+    def mode(self, value):
+        self.editBar.mode = value
+
+    def mode_hint(self, value):
+        """Change mode if make sens (i.e.: if there is nothing in the editBar)"""
+        if not self.editBar.get_edit_text():
+            self.mode = value
+
+    def debug(self):
+        """convenient method to reset screen and launch (i)p(u)db"""
+        log.info('Entered debug mode')
+        try:
+            import pudb
+            pudb.set_trace()
+        except ImportError:
+            import os
+            os.system('reset')
+            try:
+                import ipdb
+                ipdb.set_trace()
+            except ImportError:
+                import pdb
+                pdb.set_trace()
+
+    def redraw(self):
+        """redraw the screen"""
+        try:
+            self.loop.draw_screen()
+        except AttributeError:
+            pass
+
+    def start(self):
+        self.connect_bridge()
+        self.loop.run()
+
+    def post_init(self):
+        try:
+            config.apply_config(self)
+        except Exception as e:
+            log.error(u"configuration error: {}".format(e))
+            popup = self.alert(_(u"Configuration Error"), _(u"Something went wrong while reading the configuration, please check :messages"))
+            if self.options.profile:
+                self._early_popup = popup
+            else:
+                self.show_pop_up(popup)
+        super(PrimitivusApp, self).post_init(self.main_widget)
+
+    def keys_to_text(self, keys):
+        """Generator return normal text from urwid keys"""
+        for k in keys:
+            if k == 'tab':
+                yield u'\t'
+            elif k == 'enter':
+                yield u'\n'
+            elif is_wide_char(k,0) or (len(k)==1 and ord(k) >= 32):
+                yield k
+
+    def input_filter(self, input_, raw):
+        if self.__saved_overlay and input_ != a_key['OVERLAY_HIDE']:
+            return
+
+        ## paste detection/handling
+        if (len(input_) > 1 and                  # XXX: it may be needed to increase this value if buffer
+            not isinstance(input_[0], tuple) and #      or other things result in several chars at once
+            not 'window resize' in input_):      #      (e.g. using Primitivus through ssh). Need some testing
+                                                 #      and experience to adjust value.
+            if input_[0] == 'begin paste' and not self._bracketed_paste:
+                log.info(u"Bracketed paste mode detected")
+                self._bracketed_paste = True
+
+            if self._bracketed_paste:
+                # after this block, extra will contain non pasted keys
+                # and input_ will contain pasted keys
+                try:
+                    begin_idx = input_.index('begin paste')
+                except ValueError:
+                    # this is not a paste, maybe we have something buffering
+                    # or bracketed mode is set in conf but not enabled in term
+                    extra = input_
+                    input_ = []
+                else:
+                    try:
+                        end_idx = input_.index('end paste')
+                    except ValueError:
+                        log.warning(u"missing end paste sequence, discarding paste")
+                        extra = input_[:begin_idx]
+                        del input_[begin_idx:]
+                    else:
+                        extra = input_[:begin_idx] + input_[end_idx+1:]
+                        input_ = input_[begin_idx+1:end_idx]
+            else:
+                extra = None
+
+            log.debug(u"Paste detected (len {})".format(len(input_)))
+            try:
+                edit_bar = self.editBar
+            except AttributeError:
+                log.warning(u"Paste treated as normal text: there is no edit bar yet")
+                if extra is None:
+                    extra = []
+                extra.extend(input_)
+            else:
+                if self.main_widget.focus == edit_bar:
+                    # XXX: if a paste is detected, we append it directly to the edit bar text
+                    #      so the user can check it and press [enter] if it's OK
+                    buf_paste = u''.join(self.keys_to_text(input_))
+                    pos = edit_bar.edit_pos
+                    edit_bar.set_edit_text(u'{}{}{}'.format(edit_bar.edit_text[:pos], buf_paste, edit_bar.edit_text[pos:]))
+                    edit_bar.edit_pos+=len(buf_paste)
+                else:
+                    # we are not on the edit_bar,
+                    # so we treat pasted text as normal text
+                    if extra is None:
+                        extra = []
+                    extra.extend(input_)
+            if not extra:
+                return
+            input_ = extra
+        ## end of paste detection/handling
+
+        for i in input_:
+            if isinstance(i,tuple):
+                if i[0] == 'mouse press':
+                    if i[1] == 4: #Mouse wheel up
+                        input_[input_.index(i)] = a_key['HISTORY_PREV']
+                    if i[1] == 5: #Mouse wheel down
+                        input_[input_.index(i)] = a_key['HISTORY_NEXT']
+        return input_
+
+    def key_handler(self, input_):
+        if input_ == a_key['MENU_HIDE']:
+            """User want to (un)hide the menu roller"""
+            try:
+                self.main_widget.hide_switch('menu')
+            except AttributeError:
+                pass
+        elif input_ == a_key['NOTIFICATION_NEXT']:
+            """User wants to see next notification"""
+            self.notif_bar.show_next()
+        elif input_ == a_key['OVERLAY_HIDE']:
+            """User wants to (un)hide overlay window"""
+            if isinstance(self.loop.widget,urwid.Overlay):
+                self.__saved_overlay = self.loop.widget
+                self.loop.widget = self.main_widget
+            else:
+                if self.__saved_overlay:
+                    self.loop.widget = self.__saved_overlay
+                    self.__saved_overlay = None
+
+        elif input_ == a_key['DEBUG'] and 'D' in self.bridge.version_get(): #Debug only for dev versions
+            self.debug()
+        elif input_ == a_key['CONTACTS_HIDE']: #user wants to (un)hide the contact lists
+            try:
+                for wid, options in self.center_part.contents:
+                    if self.contact_lists_pile is wid:
+                        self.center_part.contents.remove((wid, options))
+                        break
+                else:
+                    self.center_part.contents.insert(0, (self.contact_lists_pile, ('weight', 2, False)))
+            except AttributeError:
+                #The main widget is not built (probably in Profile Manager)
+                pass
+        elif input_ == 'window resize':
+            width,height = self.loop.screen_size
+            if height<=5 and width<=35:
+                if not 'save_main_widget' in dir(self):
+                    self.save_main_widget = self.loop.widget
+                    self.loop.widget = urwid.Filler(urwid.Text(_("Pleeeeasse, I can't even breathe !")))
+            else:
+                if 'save_main_widget' in dir(self):
+                    self.loop.widget = self.save_main_widget
+                    del self.save_main_widget
+        try:
+            return self.menu_roller.check_shortcuts(input_)
+        except AttributeError:
+            return input_
+
+    def add_menus(self, menu, type_filter, menu_data=None):
+        """Add cached menus to instance
+        @param menu: sat_widgets.Menu instance
+        @param type_filter: menu type like is sat.core.sat_main.import_menu
+        @param menu_data: data to send with these menus
+
+        """
+        def add_menu_cb(callback_id):
+            self.action_launch(callback_id, menu_data, profile=self.current_profile)
+        for id_, type_, path, path_i18n, extra  in self.bridge.menus_get("", C.NO_SECURITY_LIMIT ): # TODO: manage extra
+            if type_ != type_filter:
+                continue
+            if len(path) != 2:
+                raise NotImplementedError("Menu with a path != 2 are not implemented yet")
+            menu.add_menu(path_i18n[0], path_i18n[1], lambda dummy,id_=id_: add_menu_cb(id_))
+
+
+    def _build_menu_roller(self):
+        menu = sat_widgets.Menu(self.loop)
+        general = _("General")
+        menu.add_menu(general, _("Connect"), self.on_connect_request)
+        menu.add_menu(general, _("Disconnect"), self.on_disconnect_request)
+        menu.add_menu(general, _("Parameters"), self.on_param)
+        menu.add_menu(general, _("About"), self.on_about_request)
+        menu.add_menu(general, _("Exit"), self.on_exit_request, a_key['APP_QUIT'])
+        menu.add_menu(_("Contacts"))  # add empty menu to save the place in the menu order
+        groups = _("Groups")
+        menu.add_menu(groups)
+        menu.add_menu(groups, _("Join room"), self.on_join_room_request, a_key['ROOM_JOIN'])
+        #additionals menus
+        #FIXME: do this in a more generic way (in quickapp)
+        self.add_menus(menu, C.MENU_GLOBAL)
+
+        menu_roller = sat_widgets.MenuRoller([(_('Main menu'), menu, C.MENU_ID_MAIN)])
+        return menu_roller
+
+    def _build_main_widget(self):
+        self.contact_lists_pile = urwid.Pile([])
+        #self.center_part = urwid.Columns([('weight',2,self.contact_lists[profile]),('weight',8,Chat('',self))])
+        self.center_part = urwid.Columns([('weight', 2, self.contact_lists_pile), ('weight', 8, urwid.Filler(urwid.Text('')))])
+
+        self.editBar = EditBar(self)
+        self.menu_roller = self._build_menu_roller()
+        self.main_widget = PrimitivusTopWidget(self.center_part, self.menu_roller, self.notif_bar, self.editBar)
+        return self.main_widget
+
+    def plugging_profiles(self):
+        self.loop.widget = self._build_main_widget()
+        self.redraw()
+        try:
+            # if a popup arrived before main widget is build, we need to show it now
+            self.show_pop_up(self._early_popup)
+        except AttributeError:
+            pass
+        else:
+            del self._early_popup
+
+    def profile_plugged(self, profile):
+        QuickApp.profile_plugged(self, profile)
+        contact_list = self.widgets.get_or_create_widget(ContactList, None, on_new_widget=None, on_click=self.contact_selected, on_change=lambda w: self.redraw(), profile=profile)
+        self.contact_lists_pile.contents.append((contact_list, ('weight', 1)))
+        return contact_list
+
+    def is_hidden(self):
+        """Tells if the frontend window is hidden.
+
+        @return bool
+        """
+        return False  # FIXME: implement when necessary
+
+    def alert(self, title, message):
+        """Shortcut method to create an alert message
+
+        Alert will have an "OK" button, which remove it if pressed
+        @param title(unicode): title of the dialog
+        @param message(unicode): body of the dialog
+        @return (urwid_satext.Alert): the created Alert instance
+        """
+        popup = sat_widgets.Alert(title, message)
+        popup.set_callback('ok', lambda dummy: self.remove_pop_up(popup))
+        self.show_pop_up(popup, width=75, height=20)
+        return popup
+
+    def remove_pop_up(self, widget=None):
+        """Remove current pop-up, and if there is other in queue, show it
+
+        @param widget(None, urwid.Widget): if not None remove this popup from front or queue
+        """
+        # TODO: refactor popup management in a cleaner way
+        # buttons' callback use themselve as first argument, and we never use
+        # a Button directly in a popup, so we consider urwid.Button as None
+        if widget is not None and not isinstance(widget, urwid.Button):
+            if isinstance(self.loop.widget, urwid.Overlay):
+                current_popup = self.loop.widget.top_w
+                if not current_popup == widget:
+                    try:
+                        self.notif_bar.remove_pop_up(widget)
+                    except ValueError:
+                        log.warning(u"Trying to remove an unknown widget {}".format(widget))
+                    return
+        self.loop.widget = self.main_widget
+        next_popup = self.notif_bar.get_next_popup()
+        if next_popup:
+            #we still have popup to show, we display it
+            self.show_pop_up(next_popup)
+        else:
+            self.redraw()
+
+    def show_pop_up(self, pop_up_widget, width=None, height=None, align='center',
+                  valign='middle'):
+        """Show a pop-up window if possible, else put it in queue
+
+        @param pop_up_widget: pop up to show
+        @param width(int, None): width of the popup
+            None to use default
+        @param height(int, None): height of the popup
+            None to use default
+        @param align: same as for [urwid.Overlay]
+        """
+        if width == None:
+            width = 75 if isinstance(pop_up_widget, xmlui.PrimitivusNoteDialog) else 135
+        if height == None:
+            height = 20 if isinstance(pop_up_widget, xmlui.PrimitivusNoteDialog) else 40
+        if not isinstance(self.loop.widget, urwid.Overlay):
+            display_widget = urwid.Overlay(
+                pop_up_widget, self.main_widget, align, width, valign, height)
+            self.loop.widget = display_widget
+            self.redraw()
+        else:
+            self.notif_bar.add_pop_up(pop_up_widget)
+
+    def bar_notify(self, message):
+        """"Notify message to user via notification bar"""
+        self.notif_bar.add_message(message)
+        self.redraw()
+
+    def notify(self, type_, entity=None, message=None, subject=None, callback=None, cb_args=None, widget=None, profile=C.PROF_KEY_NONE):
+        if widget is None or widget is not None and widget != self.selected_widget:
+            # we ignore notification if the widget is selected but we can
+            # still do a desktop notification is the X window has not the focus
+            super(PrimitivusApp, self).notify(type_, entity, message, subject, callback, cb_args, widget, profile)
+        # we don't want notifications without message on desktop
+        if message is not None and not self.x_notify.has_focus():
+            if message is None:
+                message = _("{app}: a new event has just happened{entity}").format(
+                    app=C.APP_NAME,
+                    entity=u' ({})'.format(entity) if entity else '')
+            self.x_notify.send_notification(message)
+
+
+    def new_widget(self, widget, user_action=False):
+        """Method called when a new widget is created
+
+        if suitable, the widget will be displayed
+        @param widget(widget.PrimitivusWidget): created widget
+        @param user_action(bool): if True, the widget has been created following an
+            explicit user action. In this case, the widget may get focus immediately
+        """
+        # FIXME: when several widgets are possible (e.g. with :split)
+        #        do not replace current widget when self.selected_widget != None
+        if user_action or self.selected_widget is None:
+            self.select_widget(widget)
+
+    def select_widget(self, widget):
+        """Display a widget if possible,
+
+        else add it in the notification bar queue
+        @param widget: BoxWidget
+        """
+        assert len(self.center_part.widget_list)<=2
+        wid_idx = len(self.center_part.widget_list)-1
+        self.center_part.widget_list[wid_idx] = widget
+        try:
+            self.menu_roller.remove_menu(C.MENU_ID_WIDGET)
+        except KeyError:
+            log.debug("No menu to delete")
+        self.selected_widget = widget
+        try:
+            on_selected = self.selected_widget.on_selected
+        except AttributeError:
+            pass
+        else:
+            on_selected()
+        self._visible_widgets = set([widget]) # XXX: we can only have one widget visible at the time for now
+        self.contact_lists.select(None)
+
+        for wid in self.visible_widgets: # FIXME: check if widgets.get_widgets is not more appropriate
+            if isinstance(wid, Chat):
+                contact_list = self.contact_lists[wid.profile]
+                contact_list.select(wid.target)
+
+        self.redraw()
+
+    def remove_window(self):
+        """Remove window showed on the right column"""
+        #TODO: better Window management than this hack
+        assert len(self.center_part.widget_list) <= 2
+        wid_idx = len(self.center_part.widget_list)-1
+        self.center_part.widget_list[wid_idx] = urwid.Filler(urwid.Text(''))
+        self.center_part.focus_position = 0
+        self.redraw()
+
+    def add_progress(self, pid, message, profile):
+        """Follow a SàT progression
+
+        @param pid: progression id
+        @param message: message to show to identify the progression
+        """
+        self.progress_wid.add(pid, message, profile)
+
+    def set_progress(self, percentage):
+        """Set the progression shown in notification bar"""
+        self.notif_bar.set_progress(percentage)
+
+    def contact_selected(self, contact_list, entity):
+        self.clear_notifs(entity, profile=contact_list.profile)
+        if entity.resource:
+            # we have clicked on a private MUC conversation
+            chat_widget = self.widgets.get_or_create_widget(Chat, entity, on_new_widget=None, force_hash = Chat.get_private_hash(contact_list.profile, entity), profile=contact_list.profile)
+        else:
+            chat_widget = self.widgets.get_or_create_widget(Chat, entity, on_new_widget=None, profile=contact_list.profile)
+        self.select_widget(chat_widget)
+        self.menu_roller.add_menu(_('Chat menu'), chat_widget.get_menu(), C.MENU_ID_WIDGET)
+
+    def _dialog_ok_cb(self, widget, data):
+        popup, answer_cb, answer_data = data
+        self.remove_pop_up(popup)
+        if answer_cb is not None:
+            answer_cb(True, answer_data)
+
+    def _dialog_cancel_cb(self, widget, data):
+        popup, answer_cb, answer_data = data
+        self.remove_pop_up(popup)
+        if answer_cb is not None:
+            answer_cb(False, answer_data)
+
+    def show_dialog(self, message, title="", type="info", answer_cb = None, answer_data = None):
+        if type == 'info':
+            popup = sat_widgets.Alert(title, message, ok_cb=answer_cb)
+            if answer_cb is None:
+                popup.set_callback('ok', lambda dummy: self.remove_pop_up(popup))
+        elif type == 'error':
+            popup = sat_widgets.Alert(title, message, ok_cb=answer_cb)
+            if answer_cb is None:
+                popup.set_callback('ok', lambda dummy: self.remove_pop_up(popup))
+        elif type == 'yes/no':
+            popup = sat_widgets.ConfirmDialog(message)
+            popup.set_callback('yes', self._dialog_ok_cb, (popup, answer_cb, answer_data))
+            popup.set_callback('no', self._dialog_cancel_cb, (popup, answer_cb, answer_data))
+        else:
+            popup = sat_widgets.Alert(title, message, ok_cb=answer_cb)
+            if answer_cb is None:
+                popup.set_callback('ok', lambda dummy: self.remove_pop_up(popup))
+            log.error(u'unmanaged dialog type: {}'.format(type))
+        self.show_pop_up(popup)
+
+    def dialog_failure(self, failure):
+        """Show a failure that has been returned by an asynchronous bridge method.
+
+        @param failure (defer.Failure): Failure instance
+        """
+        self.alert(failure.classname, failure.message)
+
+    def on_notification(self, notif_bar):
+        """Called when a new notification has been received"""
+        if not isinstance(self.main_widget, PrimitivusTopWidget):
+            #if we are not in the main configuration, we ignore the notifications bar
+            return
+        if self.notif_bar.can_hide():
+                #No notification left, we can hide the bar
+                self.main_widget.hide('notif_bar')
+        else:
+            self.main_widget.show('notif_bar')
+            self.redraw() # FIXME: invalidate cache in a more efficient way
+
+    def _action_manager_unknown_error(self):
+        self.alert(_("Error"), _(u"Unmanaged action"))
+
+    def room_joined_handler(self, room_jid_s, room_nicks, user_nick, subject, profile):
+        super(PrimitivusApp, self).room_joined_handler(room_jid_s, room_nicks, user_nick, subject, profile)
+        # if self.selected_widget is None:
+        #     for contact_list in self.widgets.get_widgets(ContactList):
+        #         if profile in contact_list.profiles:
+        #             contact_list.set_focus(jid.JID(room_jid_s), True)
+
+    def progress_started_handler(self, pid, metadata, profile):
+        super(PrimitivusApp, self).progress_started_handler(pid, metadata, profile)
+        self.add_progress(pid, metadata.get('name', _(u'unkown')), profile)
+
+    def progress_finished_handler(self, pid, metadata, profile):
+        log.info(u"Progress {} finished".format(pid))
+        super(PrimitivusApp, self).progress_finished_handler(pid, metadata, profile)
+
+    def progress_error_handler(self, pid, err_msg, profile):
+        log.warning(u"Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg))
+        super(PrimitivusApp, self).progress_error_handler(pid, err_msg, profile)
+
+
+    ##DIALOGS CALLBACKS##
+    def on_join_room(self, button, edit):
+        self.remove_pop_up()
+        room_jid = jid.JID(edit.get_edit_text())
+        self.bridge.muc_join(room_jid, self.profiles[self.current_profile].whoami.node, {}, self.current_profile, callback=lambda dummy: None, errback=self.dialog_failure)
+
+    #MENU EVENTS#
+    def on_connect_request(self, menu):
+        QuickApp.connect(self, self.current_profile)
+
+    def on_disconnect_request(self, menu):
+        self.disconnect(self.current_profile)
+
+    def on_param(self, menu):
+        def success(params):
+            ui = xmlui.create(self, xml_data=params, profile=self.current_profile)
+            ui.show()
+
+        def failure(error):
+            self.alert(_("Error"), _("Can't get parameters (%s)") % error)
+        self.bridge.param_ui_get(app=C.APP_NAME, profile_key=self.current_profile, callback=success, errback=failure)
+
+    def on_exit_request(self, menu):
+        QuickApp.on_exit(self)
+        try:
+            if self._bracketed_mode_set: # we don't unset if bracketed paste mode was detected automatically (i.e. not in conf)
+                log.debug("unsetting bracketed paste mode")
+                sys.stdout.write("\033[?2004l")
+        except AttributeError:
+            pass
+        raise urwid.ExitMainLoop()
+
+    def on_join_room_request(self, menu):
+        """User wants to join a MUC room"""
+        pop_up_widget = sat_widgets.InputDialog(_("Entering a MUC room"), _("Please enter MUC's JID"), default_txt=self.bridge.muc_get_default_service(), ok_cb=self.on_join_room)
+        pop_up_widget.set_callback('cancel', lambda dummy: self.remove_pop_up(pop_up_widget))
+        self.show_pop_up(pop_up_widget)
+
+    def on_about_request(self, menu):
+        self.alert(_("About"), C.APP_NAME + " v" + self.bridge.version_get())
+
+    #MISC CALLBACKS#
+
+    def set_presence_status(self, show='', status=None, profile=C.PROF_KEY_NONE):
+        contact_list_wid = self.widgets.get_widget(ContactList, profiles=profile)
+        if contact_list_wid is not None:
+            contact_list_wid.status_bar.set_presence_status(show, status)
+        else:
+            log.warning(u"No ContactList widget found for profile {}".format(profile))
+
+if __name__ == '__main__':
+    PrimitivusApp().start()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/chat.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,708 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 functools import total_ordering
+from pathlib import Path
+import bisect
+import urwid
+from urwid_satext import sat_widgets
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.frontends.quick_frontend import quick_chat
+from libervia.frontends.quick_frontend import quick_games
+from libervia.frontends.primitivus import game_tarot
+from libervia.frontends.primitivus.constants import Const as C
+from libervia.frontends.primitivus.keys import action_key_map as a_key
+from libervia.frontends.primitivus.widget import PrimitivusWidget
+from libervia.frontends.primitivus.contact_list import ContactList
+
+
+log = logging.getLogger(__name__)
+
+
+OCCUPANTS_FOOTER = _("{} occupants")
+
+
+class MessageWidget(urwid.WidgetWrap, quick_chat.MessageWidget):
+    def __init__(self, mess_data):
+        """
+        @param mess_data(quick_chat.Message, None): message data
+            None: used only for non text widgets (e.g.: focus separator)
+        """
+        self.mess_data = mess_data
+        mess_data.widgets.add(self)
+        super(MessageWidget, self).__init__(urwid.Text(self.markup))
+
+    @property
+    def markup(self):
+        return (
+            self._generate_info_markup()
+            if self.mess_data.type == C.MESS_TYPE_INFO
+            else self._generate_markup()
+        )
+
+    @property
+    def info_type(self):
+        return self.mess_data.info_type
+
+    @property
+    def parent(self):
+        return self.mess_data.parent
+
+    @property
+    def message(self):
+        """Return currently displayed message"""
+        return self.mess_data.main_message
+
+    @message.setter
+    def message(self, value):
+        self.mess_data.message = {"": value}
+        self.redraw()
+
+    @property
+    def type(self):
+        try:
+            return self.mess_data.type
+        except AttributeError:
+            return C.MESS_TYPE_INFO
+
+    def redraw(self):
+        self._w.set_text(self.markup)
+        self.mess_data.parent.host.redraw()  # FIXME: should not be necessary
+
+    def selectable(self):
+        return True
+
+    def keypress(self, size, key):
+        return key
+
+    def get_cursor_coords(self, size):
+        return 0, 0
+
+    def render(self, size, focus=False):
+        # Text widget doesn't render cursor, but we want one
+        # so we add it here
+        canvas = urwid.CompositeCanvas(self._w.render(size, focus))
+        if focus:
+            canvas.set_cursor(self.get_cursor_coords(size))
+        return canvas
+
+    def _generate_info_markup(self):
+        return ("info_msg", self.message)
+
+    def _generate_markup(self):
+        """Generate text markup according to message data and Widget options"""
+        markup = []
+        d = self.mess_data
+        mention = d.mention
+
+        # message status
+        if d.status is None:
+            markup.append(" ")
+        elif d.status == "delivered":
+            markup.append(("msg_status_received", "✔"))
+        else:
+            log.warning("Unknown status: {}".format(d.status))
+
+        # timestamp
+        if self.parent.show_timestamp:
+            attr = "msg_mention" if mention else "date"
+            markup.append((attr, "[{}]".format(d.time_text)))
+        else:
+            if mention:
+                markup.append(("msg_mention", "[*]"))
+
+        # nickname
+        if self.parent.show_short_nick:
+            markup.append(
+                ("my_nick" if d.own_mess else "other_nick", "**" if d.own_mess else "*")
+            )
+        else:
+            markup.append(
+                ("my_nick" if d.own_mess else "other_nick", "[{}] ".format(d.nick or ""))
+            )
+
+        msg = self.message  # needed to generate self.selected_lang
+
+        if d.selected_lang:
+            markup.append(("msg_lang", "[{}] ".format(d.selected_lang)))
+
+        # message body
+        markup.append(msg)
+
+        return markup
+
+    # events
+    def update(self, update_dict=None):
+        """update all the linked message widgets
+
+        @param update_dict(dict, None): key=attribute updated value=new_value
+        """
+        self.redraw()
+
+
+@total_ordering
+class OccupantWidget(urwid.WidgetWrap):
+    def __init__(self, occupant_data):
+        self.occupant_data = occupant_data
+        occupant_data.widgets.add(self)
+        markup = self._generate_markup()
+        text = sat_widgets.ClickableText(markup)
+        urwid.connect_signal(
+            text,
+            "click",
+            self.occupant_data.parent._occupants_clicked,
+            user_args=[self.occupant_data],
+        )
+        super(OccupantWidget, self).__init__(text)
+
+    def __hash__(self):
+        return id(self)
+
+    def __eq__(self, other):
+        if other is None:
+            return False
+        return self.occupant_data.nick == other.occupant_data.nick
+
+    def __lt__(self, other):
+        return self.occupant_data.nick.lower() < other.occupant_data.nick.lower()
+
+    @property
+    def markup(self):
+        return self._generate_markup()
+
+    @property
+    def parent(self):
+        return self.mess_data.parent
+
+    @property
+    def nick(self):
+        return self.occupant_data.nick
+
+    def redraw(self):
+        self._w.set_text(self.markup)
+        self.occupant_data.parent.host.redraw()  # FIXME: should not be necessary
+
+    def selectable(self):
+        return True
+
+    def keypress(self, size, key):
+        return key
+
+    def get_cursor_coords(self, size):
+        return 0, 0
+
+    def render(self, size, focus=False):
+        # Text widget doesn't render cursor, but we want one
+        # so we add it here
+        canvas = urwid.CompositeCanvas(self._w.render(size, focus))
+        if focus:
+            canvas.set_cursor(self.get_cursor_coords(size))
+        return canvas
+
+    def _generate_markup(self):
+        # TODO: role and affiliation are shown in a Q&D way
+        #       should be more intuitive and themable
+        o = self.occupant_data
+        markup = []
+        markup.append(
+            ("info_msg", "{}{} ".format(o.role[0].upper(), o.affiliation[0].upper()))
+        )
+        markup.append(o.nick)
+        if o.state is not None:
+            markup.append(" {}".format(C.CHAT_STATE_ICON[o.state]))
+        return markup
+
+    # events
+    def update(self, update_dict=None):
+        self.redraw()
+
+
+class OccupantsWidget(urwid.WidgetWrap):
+    def __init__(self, parent):
+        self.parent = parent
+        self.occupants_walker = urwid.SimpleListWalker([])
+        self.occupants_footer = urwid.Text("", align="center")
+        self.update_footer()
+        occupants_widget = urwid.Frame(
+            urwid.ListBox(self.occupants_walker), footer=self.occupants_footer
+        )
+        super(OccupantsWidget, self).__init__(occupants_widget)
+        occupants_list = sorted(list(self.parent.occupants.keys()), key=lambda o: o.lower())
+        for occupant in occupants_list:
+            occupant_data = self.parent.occupants[occupant]
+            self.occupants_walker.append(OccupantWidget(occupant_data))
+
+    def clear(self):
+        del self.occupants_walker[:]
+
+    def update_footer(self):
+        """update footer widget"""
+        txt = OCCUPANTS_FOOTER.format(len(self.parent.occupants))
+        self.occupants_footer.set_text(txt)
+
+    def get_nicks(self, start=""):
+        """Return nicks of all occupants
+
+        @param start(unicode): only return nicknames which start with this text
+        """
+        return [
+            w.nick
+            for w in self.occupants_walker
+            if isinstance(w, OccupantWidget) and w.nick.startswith(start)
+        ]
+
+    def addUser(self, occupant_data):
+        """add a user to the list"""
+        bisect.insort(self.occupants_walker, OccupantWidget(occupant_data))
+        self.update_footer()
+        self.parent.host.redraw()  # FIXME: should not be necessary
+
+    def removeUser(self, occupant_data):
+        """remove a user from the list"""
+        for widget in occupant_data.widgets:
+            self.occupants_walker.remove(widget)
+        self.update_footer()
+        self.parent.host.redraw()  # FIXME: should not be necessary
+
+
+class Chat(PrimitivusWidget, quick_chat.QuickChat):
+    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None,
+                 subject=None, statuses=None, profiles=None):
+        self.filters = []  # list of filter callbacks to apply
+        self.mess_walker = urwid.SimpleListWalker([])
+        self.mess_widgets = urwid.ListBox(self.mess_walker)
+        self.chat_widget = urwid.Frame(self.mess_widgets)
+        self.chat_colums = urwid.Columns([("weight", 8, self.chat_widget)])
+        self.pile = urwid.Pile([self.chat_colums])
+        PrimitivusWidget.__init__(self, self.pile, target)
+        quick_chat.QuickChat.__init__(
+            self, host, target, type_, nick, occupants, subject, statuses,
+            profiles=profiles
+        )
+
+        # we must adapt the behaviour with the type
+        if type_ == C.CHAT_GROUP:
+            if len(self.chat_colums.contents) == 1:
+                self.occupants_widget = OccupantsWidget(self)
+                self.occupants_panel = sat_widgets.VerticalSeparator(
+                    self.occupants_widget
+                )
+                self._append_occupants_panel()
+                self.host.addListener("presence", self.presence_listener, [profiles])
+
+        # focus marker is a separator indicated last visible message before focus was lost
+        self.focus_marker = None  # link to current marker
+        self.focus_marker_set = None  # True if a new marker has been inserted
+        self.show_timestamp = True
+        self.show_short_nick = False
+        self.show_title = 1  # 0: clip title; 1: full title; 2: no title
+        self.post_init()
+
+    @property
+    def message_widgets_rev(self):
+        return reversed(self.mess_walker)
+
+    def keypress(self, size, key):
+        if key == a_key["OCCUPANTS_HIDE"]:  # user wants to (un)hide the occupants panel
+            if self.type == C.CHAT_GROUP:
+                widgets = [widget for (widget, options) in self.chat_colums.contents]
+                if self.occupants_panel in widgets:
+                    self._remove_occupants_panel()
+                else:
+                    self._append_occupants_panel()
+        elif key == a_key["TIMESTAMP_HIDE"]:  # user wants to (un)hide timestamp
+            self.show_timestamp = not self.show_timestamp
+            self.redraw()
+        elif key == a_key["SHORT_NICKNAME"]:  # user wants to (not) use short nick
+            self.show_short_nick = not self.show_short_nick
+            self.redraw()
+        elif (key == a_key["SUBJECT_SWITCH"]):
+            # user wants to (un)hide group's subject or change its apperance
+            if self.subject:
+                self.show_title = (self.show_title + 1) % 3
+                if self.show_title == 0:
+                    self.set_subject(self.subject, "clip")
+                elif self.show_title == 1:
+                    self.set_subject(self.subject, "space")
+                elif self.show_title == 2:
+                    self.chat_widget.header = None
+                self._invalidate()
+        elif key == a_key["GOTO_BOTTOM"]:  # user wants to focus last message
+            self.mess_widgets.focus_position = len(self.mess_walker) - 1
+
+        return super(Chat, self).keypress(size, key)
+
+    def completion(self, text, completion_data):
+        """Completion method which complete nicknames in group chat
+
+        for params, see [sat_widgets.AdvancedEdit]
+        """
+        if self.type != C.CHAT_GROUP:
+            return text
+
+        space = text.rfind(" ")
+        start = text[space + 1 :]
+        words = self.occupants_widget.get_nicks(start)
+        if not words:
+            return text
+        try:
+            word_idx = words.index(completion_data["last_word"]) + 1
+        except (KeyError, ValueError):
+            word_idx = 0
+        else:
+            if word_idx == len(words):
+                word_idx = 0
+        word = completion_data["last_word"] = words[word_idx]
+        return "{}{}{}".format(text[: space + 1], word, ": " if space < 0 else "")
+
+    def get_menu(self):
+        """Return Menu bar"""
+        menu = sat_widgets.Menu(self.host.loop)
+        if self.type == C.CHAT_GROUP:
+            self.host.add_menus(menu, C.MENU_ROOM, {"room_jid": self.target.bare})
+            game = _("Game")
+            menu.add_menu(game, "Tarot", self.on_tarot_request)
+        elif self.type == C.CHAT_ONE2ONE:
+            # FIXME: self.target is a bare jid, we need to check that
+            contact_list = self.host.contact_lists[self.profile]
+            if not self.target.resource:
+                full_jid = contact_list.get_full_jid(self.target)
+            else:
+                full_jid = self.target
+            self.host.add_menus(menu, C.MENU_SINGLE, {"jid": full_jid})
+        return menu
+
+    def set_filter(self, args):
+        """set filtering of messages
+
+        @param args(list[unicode]): filters following syntax "[filter]=[value]"
+            empty list to clear all filters
+            only lang=XX is handled for now
+        """
+        del self.filters[:]
+        if args:
+            if args[0].startswith("lang="):
+                lang = args[0][5:].strip()
+                self.filters.append(lambda mess_data: lang in mess_data.message)
+
+        self.print_messages()
+
+    def presence_listener(self, entity, show, priority, statuses, profile):
+        """Update entity's presence status
+
+        @param entity (jid.JID): entity updated
+        @param show: availability
+        @param priority: resource's priority
+        @param statuses: dict of statuses
+        @param profile: %(doc_profile)s
+        """
+        # FIXME: disable for refactoring, need to be checked and re-enabled
+        return
+        # assert self.type == C.CHAT_GROUP
+        # if entity.bare != self.target:
+        #     return
+        # self.update(entity)
+
+    def create_message(self, message):
+        self.appendMessage(message)
+
+    def _scrollDown(self):
+        """scroll down message only if we are already at the bottom (minus 1)"""
+        current_focus = self.mess_widgets.focus_position
+        bottom = len(self.mess_walker) - 1
+        if current_focus == bottom - 1:
+            self.mess_widgets.focus_position = bottom  # scroll down
+        self.host.redraw()  # FIXME: should not be necessary
+
+    def appendMessage(self, message, minor_notifs=True):
+        """Create a MessageWidget and append it
+
+        Can merge info messages together if desirable (e.g.: multiple joined/leave)
+        @param message(quick_chat.Message): message to add
+        @param minor_notifs(boolean): if True, basic notifications are allowed
+            If False, notification are not shown except if we have an important one
+            (like a mention).
+            False is generally used when printing history, when we don't want every
+            message to be notified.
+        """
+        if message.attachments:
+            # FIXME: Q&D way to see attachments in Primitivus
+            #   it should be done in a more user friendly way
+            for lang, body in message.message.items():
+                for attachment in message.attachments:
+                    if 'url' in attachment:
+                        body+=f"\n{attachment['url']}"
+                    elif 'path' in attachment:
+                        path = Path(attachment['path'])
+                        body+=f"\n{path.as_uri()}"
+                    else:
+                        log.warning(f'No "url" nor "path" in attachment: {attachment}')
+                    message.message[lang] = body
+
+        if self.filters:
+            if not all([f(message) for f in self.filters]):
+                return
+
+        if self.handle_user_moved(message):
+            return
+
+        if ((self.host.selected_widget != self or not self.host.x_notify.has_focus())
+            and self.focus_marker_set is not None):
+            if not self.focus_marker_set and not self._locked and self.mess_walker:
+                if self.focus_marker is not None:
+                    try:
+                        self.mess_walker.remove(self.focus_marker)
+                    except ValueError:
+                        # self.focus_marker may not be in mess_walker anymore if
+                        # mess_walker has been cleared, e.g. when showing search
+                        # result or using :history command
+                        pass
+                self.focus_marker = urwid.Divider("—")
+                self.mess_walker.append(self.focus_marker)
+                self.focus_marker_set = True
+                self._scrollDown()
+        else:
+            if self.focus_marker_set:
+                self.focus_marker_set = False
+
+        wid = MessageWidget(message)
+        self.mess_walker.append(wid)
+        self._scrollDown()
+        if self.is_user_moved(message):
+            return  # no notification for moved messages
+
+        # notifications
+
+        if self._locked:
+            # we don't want notifications when locked
+            # because that's history messages
+            return
+
+        if wid.mess_data.mention:
+            from_jid = wid.mess_data.from_jid
+            msg = _(
+                "You have been mentioned by {nick} in {room}".format(
+                    nick=wid.mess_data.nick, room=self.target
+                )
+            )
+            self.host.notify(
+                C.NOTIFY_MENTION, from_jid, msg, widget=self, profile=self.profile
+            )
+        elif not minor_notifs:
+            return
+        elif self.type == C.CHAT_ONE2ONE:
+            from_jid = wid.mess_data.from_jid
+            msg = _("{entity} is talking to you".format(entity=from_jid))
+            self.host.notify(
+                C.NOTIFY_MESSAGE, from_jid, msg, widget=self, profile=self.profile
+            )
+        else:
+            self.host.notify(
+                C.NOTIFY_MESSAGE, self.target, widget=self, profile=self.profile
+            )
+
+    def addUser(self, nick):
+        occupant = super(Chat, self).addUser(nick)
+        self.occupants_widget.addUser(occupant)
+
+    def removeUser(self, occupant_data):
+        occupant = super(Chat, self).removeUser(occupant_data)
+        if occupant is not None:
+            self.occupants_widget.removeUser(occupant)
+
+    def occupants_clear(self):
+        super(Chat, self).occupants_clear()
+        self.occupants_widget.clear()
+
+    def _occupants_clicked(self, occupant, clicked_wid):
+        assert self.type == C.CHAT_GROUP
+        contact_list = self.host.contact_lists[self.profile]
+
+        # we have a click on a nick, we need to create the widget if it doesn't exists
+        self.get_or_create_private_widget(occupant.jid)
+
+        # now we select the new window
+        for contact_list in self.host.widgets.get_widgets(
+            ContactList, profiles=(self.profile,)
+        ):
+            contact_list.set_focus(occupant.jid, True)
+
+    def _append_occupants_panel(self):
+        self.chat_colums.contents.append((self.occupants_panel, ("weight", 2, False)))
+
+    def _remove_occupants_panel(self):
+        for widget, options in self.chat_colums.contents:
+            if widget is self.occupants_panel:
+                self.chat_colums.contents.remove((widget, options))
+                break
+
+    def add_game_panel(self, widget):
+        """Insert a game panel to this Chat dialog.
+
+        @param widget (Widget): the game panel
+        """
+        assert len(self.pile.contents) == 1
+        self.pile.contents.insert(0, (widget, ("weight", 1)))
+        self.pile.contents.insert(1, (urwid.Filler(urwid.Divider("-"), ("fixed", 1))))
+        self.host.redraw()
+
+    def remove_game_panel(self, widget):
+        """Remove the game panel from this Chat dialog.
+
+        @param widget (Widget): the game panel
+        """
+        assert len(self.pile.contents) == 3
+        del self.pile.contents[0]
+        self.host.redraw()
+
+    def set_subject(self, subject, wrap="space"):
+        """Set title for a group chat"""
+        quick_chat.QuickChat.set_subject(self, subject)
+        self.subj_wid = urwid.Text(
+            str(subject.replace("\n", "|") if wrap == "clip" else subject),
+            align="left" if wrap == "clip" else "center",
+            wrap=wrap,
+        )
+        self.chat_widget.header = urwid.AttrMap(self.subj_wid, "title")
+        self.host.redraw()
+
+    ## Messages
+
+    def print_messages(self, clear=True):
+        """generate message widgets
+
+        @param clear(bool): clear message before printing if true
+        """
+        if clear:
+            del self.mess_walker[:]
+        for message in self.messages.values():
+            self.appendMessage(message, minor_notifs=False)
+
+    def redraw(self):
+        """redraw all messages"""
+        for w in self.mess_walker:
+            try:
+                w.redraw()
+            except AttributeError:
+                pass
+
+    def update_history(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile="@NONE@"):
+        del self.mess_walker[:]
+        if filters and "search" in filters:
+            self.mess_walker.append(
+                urwid.Text(
+                    _("Results for searching the globbing pattern: {}").format(
+                        filters["search"]
+                    )
+                )
+            )
+            self.mess_walker.append(
+                urwid.Text(_("Type ':history <lines>' to reset the chat history"))
+            )
+        super(Chat, self).update_history(size, filters, profile)
+
+    def _on_history_printed(self):
+        """Refresh or scroll down the focus after the history is printed"""
+        self.print_messages(clear=False)
+        super(Chat, self)._on_history_printed()
+
+    def on_private_created(self, widget):
+        self.host.contact_lists[widget.profile].set_special(
+            widget.target, C.CONTACT_SPECIAL_GROUP
+        )
+
+    def on_selected(self):
+        self.focus_marker_set = False
+
+    def notify(self, contact="somebody", msg=""):
+        """Notify the user of a new message if primitivus doesn't have the focus.
+
+        @param contact (unicode): contact who wrote to the users
+        @param msg (unicode): the message that has been received
+        """
+        # FIXME: not called anymore after refactoring
+        if msg == "":
+            return
+        if self.mess_widgets.get_focus()[1] == len(self.mess_walker) - 2:
+            # we don't change focus if user is not at the bottom
+            # as that mean that he is probably watching discussion history
+            self.mess_widgets.focus_position = len(self.mess_walker) - 1
+        self.host.redraw()
+        if not self.host.x_notify.has_focus():
+            if self.type == C.CHAT_ONE2ONE:
+                self.host.x_notify.send_notification(
+                    _("Primitivus: %s is talking to you") % contact
+                )
+            elif self.nick is not None and self.nick.lower() in msg.lower():
+                self.host.x_notify.send_notification(
+                    _("Primitivus: %(user)s mentioned you in room '%(room)s'")
+                    % {"user": contact, "room": self.target}
+                )
+
+    # MENU EVENTS #
+    def on_tarot_request(self, menu):
+        # TODO: move this to plugin_misc_tarot with dynamic menu
+        if len(self.occupants) != 4:
+            self.host.show_pop_up(
+                sat_widgets.Alert(
+                    _("Can't start game"),
+                    _(
+                        "You need to be exactly 4 peoples in the room to start a Tarot game"
+                    ),
+                    ok_cb=self.host.remove_pop_up,
+                )
+            )
+        else:
+            self.host.bridge.tarot_game_create(
+                self.target, list(self.occupants), self.profile
+            )
+
+    # MISC EVENTS #
+
+    def on_delete(self):
+        # FIXME: to be checked after refactoring
+        super(Chat, self).on_delete()
+        if self.type == C.CHAT_GROUP:
+            self.host.removeListener("presence", self.presence_listener)
+
+    def on_chat_state(self, from_jid, state, profile):
+        super(Chat, self).on_chat_state(from_jid, state, profile)
+        if self.type == C.CHAT_ONE2ONE:
+            self.title_dynamic = C.CHAT_STATE_ICON[state]
+            self.host.redraw()  # FIXME: should not be necessary
+
+    def _on_subject_dialog_cb(self, button, dialog):
+        self.change_subject(dialog.text)
+        self.host.remove_pop_up(dialog)
+
+    def on_subject_dialog(self, new_subject=None):
+        dialog = sat_widgets.InputDialog(
+            _("Change title"),
+            _("Enter the new title"),
+            default_txt=new_subject if new_subject is not None else self.subject,
+        )
+        dialog.set_callback("ok", self._on_subject_dialog_cb, dialog)
+        dialog.set_callback("cancel", lambda __: self.host.remove_pop_up(dialog))
+        self.host.show_pop_up(dialog)
+
+
+quick_widgets.register(quick_chat.QuickChat, Chat)
+quick_widgets.register(quick_games.Tarot, game_tarot.TarotGame)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/config.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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/>.
+
+"""This module manage configuration specific to Primitivus"""
+
+from libervia.frontends.primitivus.constants import Const as C
+from libervia.frontends.primitivus.keys import action_key_map
+import configparser
+
+
+def apply_config(host):
+    """Parse configuration and apply found change
+
+    raise: can raise various Exceptions if configuration is not good
+    """
+    config = configparser.SafeConfigParser()
+    config.read(C.CONFIG_FILES)
+    try:
+        options = config.items(C.CONFIG_SECTION)
+    except configparser.NoSectionError:
+        options = []
+    shortcuts = {}
+    for name, value in options:
+        if name.startswith(C.CONFIG_OPT_KEY_PREFIX.lower()):
+            action = name[len(C.CONFIG_OPT_KEY_PREFIX) :].upper()
+            shortcut = value
+            if not action or not shortcut:
+                raise ValueError("Bad option: {} = {}".format(name, value))
+            shortcuts[action] = shortcut
+        if name == "disable_mouse":
+            host.loop.screen.set_mouse_tracking(False)
+
+    action_key_map.replace(shortcuts)
+    action_key_map.check_namespaces()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/constants.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.frontends.quick_frontend import constants
+
+
+class Const(constants.Const):
+
+    APP_NAME = "Libervia TUI"
+    APP_COMPONENT = "TUI"
+    APP_NAME_ALT = "Primitivus"
+    APP_NAME_FILE = "libervia_tui"
+    CONFIG_SECTION = APP_COMPONENT.lower()
+    PALETTE = [
+        ("title", "black", "light gray", "standout,underline"),
+        ("title_focus", "white,bold", "light gray", "standout,underline"),
+        ("selected", "default", "dark red"),
+        ("selected_focus", "default,bold", "dark red"),
+        ("default", "default", "default"),
+        ("default_focus", "default,bold", "default"),
+        ("cl_notifs", "yellow", "default"),
+        ("cl_notifs_focus", "yellow,bold", "default"),
+        ("cl_mention", "light red", "default"),
+        ("cl_mention_focus", "dark red,bold", "default"),
+        # Messages
+        ("date", "light gray", "default"),
+        ("my_nick", "dark red,bold", "default"),
+        ("other_nick", "dark cyan,bold", "default"),
+        ("info_msg", "yellow", "default", "bold"),
+        ("msg_lang", "dark cyan", "default"),
+        ("msg_mention", "dark red, bold", "default"),
+        ("msg_status_received", "light green, bold", "default"),
+        ("menubar", "light gray,bold", "dark red"),
+        ("menubar_focus", "light gray,bold", "dark green"),
+        ("selected_menu", "light gray,bold", "dark green"),
+        ("menuitem", "light gray,bold", "dark red"),
+        ("menuitem_focus", "light gray,bold", "dark green"),
+        ("notifs", "black,bold", "yellow"),
+        ("notifs_focus", "dark red", "yellow"),
+        ("card_neutral", "dark gray", "white", "standout,underline"),
+        ("card_neutral_selected", "dark gray", "dark green", "standout,underline"),
+        ("card_special", "brown", "white", "standout,underline"),
+        ("card_special_selected", "brown", "dark green", "standout,underline"),
+        ("card_red", "dark red", "white", "standout,underline"),
+        ("card_red_selected", "dark red", "dark green", "standout,underline"),
+        ("card_black", "black", "white", "standout,underline"),
+        ("card_black_selected", "black", "dark green", "standout,underline"),
+        ("directory", "dark cyan, bold", "default"),
+        ("directory_focus", "dark cyan, bold", "dark green"),
+        ("separator", "brown", "default"),
+        ("warning", "light red", "default"),
+        ("progress_normal", "default", "brown"),
+        ("progress_complete", "default", "dark green"),
+        ("show_disconnected", "dark gray", "default"),
+        ("show_normal", "default", "default"),
+        ("show_normal_focus", "default, bold", "default"),
+        ("show_chat", "dark green", "default"),
+        ("show_chat_focus", "dark green, bold", "default"),
+        ("show_away", "brown", "default"),
+        ("show_away_focus", "brown, bold", "default"),
+        ("show_dnd", "dark red", "default"),
+        ("show_dnd_focus", "dark red, bold", "default"),
+        ("show_xa", "dark red", "default"),
+        ("show_xa_focus", "dark red, bold", "default"),
+        ("resource", "light blue", "default"),
+        ("resource_main", "dark blue", "default"),
+        ("status", "yellow", "default"),
+        ("status_focus", "yellow, bold", "default"),
+        ("param_selected", "default, bold", "dark red"),
+        ("table_selected", "default, bold", "default"),
+    ]
+    PRESENCE = {
+        "unavailable": ("⨯", "show_disconnected"),
+        "": ("✔", "show_normal"),
+        "chat": ("✆", "show_chat"),
+        "away": ("✈", "show_away"),
+        "dnd": ("✖", "show_dnd"),
+        "xa": ("☄", "show_xa"),
+    }
+    LOG_OPT_SECTION = APP_NAME.lower()
+    LOG_OPT_OUTPUT = (
+        "output",
+        constants.Const.LOG_OPT_OUTPUT_SEP + constants.Const.LOG_OPT_OUTPUT_MEMORY,
+    )
+    CONFIG_OPT_KEY_PREFIX = "KEY_"
+
+    MENU_ID_MAIN = "MAIN_MENU"
+    MENU_ID_WIDGET = "WIDGET_MENU"
+
+    MODE_NORMAL = "NORMAL"
+    MODE_INSERTION = "INSERTION"
+    MODE_COMMAND = "COMMAND"
+
+    GROUP_DATA_FOLDED = "folded"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/contact_list.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,364 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+import urwid
+from urwid_satext import sat_widgets
+from libervia.frontends.quick_frontend.quick_contact_list import QuickContactList
+from libervia.frontends.primitivus.status import StatusBar
+from libervia.frontends.primitivus.constants import Const as C
+from libervia.frontends.primitivus.keys import action_key_map as a_key
+from libervia.frontends.primitivus.widget import PrimitivusWidget
+from libervia.frontends.tools import jid
+from libervia.backend.core import log as logging
+
+log = logging.getLogger(__name__)
+from libervia.frontends.quick_frontend import quick_widgets
+
+
+class ContactList(PrimitivusWidget, QuickContactList):
+    PROFILES_MULTIPLE = False
+    PROFILES_ALLOW_NONE = False
+    signals = ["click", "change"]
+    # FIXME: Only single profile is managed so far
+
+    def __init__(
+        self, host, target, on_click=None, on_change=None, user_data=None, profiles=None
+    ):
+        QuickContactList.__init__(self, host, profiles)
+        self.contact_list = self.host.contact_lists[self.profile]
+
+        # we now build the widget
+        self.status_bar = StatusBar(host)
+        self.frame = sat_widgets.FocusFrame(self._build_list(), None, self.status_bar)
+        PrimitivusWidget.__init__(self, self.frame, _("Contacts"))
+        if on_click:
+            urwid.connect_signal(self, "click", on_click, user_data)
+        if on_change:
+            urwid.connect_signal(self, "change", on_change, user_data)
+        self.host.addListener("notification", self.on_notification, [self.profile])
+        self.host.addListener("notificationsClear", self.on_notification, [self.profile])
+        self.post_init()
+
+    def update(self, entities=None, type_=None, profile=None):
+        """Update display, keep focus"""
+        # FIXME: full update is done each time, must handle entities, type_ and profile
+        widget, position = self.frame.body.get_focus()
+        self.frame.body = self._build_list()
+        if position:
+            try:
+                self.frame.body.focus_position = position
+            except IndexError:
+                pass
+        self._invalidate()
+        self.host.redraw()  # FIXME: check if can be avoided
+
+    def keypress(self, size, key):
+        # FIXME: we have a temporary behaviour here: FOCUS_SWITCH change focus globally in the parent,
+        #        and FOCUS_UP/DOWN is transwmitter to parent if we are respectively on the first or last element
+        if key in sat_widgets.FOCUS_KEYS:
+            if (
+                key == a_key["FOCUS_SWITCH"]
+                or (key == a_key["FOCUS_UP"] and self.frame.focus_position == "body")
+                or (key == a_key["FOCUS_DOWN"] and self.frame.focus_position == "footer")
+            ):
+                return key
+        if key == a_key["STATUS_HIDE"]:  # user wants to (un)hide contacts' statuses
+            self.contact_list.show_status = not self.contact_list.show_status
+            self.update()
+        elif (
+            key == a_key["DISCONNECTED_HIDE"]
+        ):  # user wants to (un)hide disconnected contacts
+            self.host.bridge.param_set(
+                C.SHOW_OFFLINE_CONTACTS,
+                C.bool_const(not self.contact_list.show_disconnected),
+                "General",
+                profile_key=self.profile,
+            )
+        elif key == a_key["RESOURCES_HIDE"]:  # user wants to (un)hide contacts resources
+            self.contact_list.show_resources(not self.contact_list.show_resources)
+            self.update()
+        return super(ContactList, self).keypress(size, key)
+
+    # QuickWidget methods
+
+    @staticmethod
+    def get_widget_hash(target, profiles):
+        profiles = sorted(profiles)
+        return tuple(profiles)
+
+    # modify the contact list
+
+    def set_focus(self, text, select=False):
+        """give focus to the first element that matches the given text. You can also
+        pass in text a libervia.frontends.tools.jid.JID (it's a subclass of unicode).
+
+        @param text: contact group name, contact or muc userhost, muc private dialog jid
+        @param select: if True, the element is also clicked
+        """
+        idx = 0
+        for widget in self.frame.body.body:
+            try:
+                if isinstance(widget, sat_widgets.ClickableText):
+                    # contact group
+                    value = widget.get_value()
+                elif isinstance(widget, sat_widgets.SelectableText):
+                    # contact or muc
+                    value = widget.data
+                else:
+                    # Divider instance
+                    continue
+                # there's sometimes a leading space
+                if text.strip() == value.strip():
+                    self.frame.body.focus_position = idx
+                    if select:
+                        self._contact_clicked(False, widget, True)
+                    return
+            except AttributeError:
+                pass
+            idx += 1
+
+        log.debug("Not element found for {} in set_focus".format(text))
+
+    # events
+
+    def _group_clicked(self, group_wid):
+        group = group_wid.get_value()
+        data = self.contact_list.get_group_data(group)
+        data[C.GROUP_DATA_FOLDED] = not data.setdefault(C.GROUP_DATA_FOLDED, False)
+        self.set_focus(group)
+        self.update()
+
+    def _contact_clicked(self, use_bare_jid, contact_wid, selected):
+        """Method called when a contact is clicked
+
+        @param use_bare_jid: True if use_bare_jid is set in self._build_entity_widget.
+        @param contact_wid: widget of the contact, must have the entity set in data attribute
+        @param selected: boolean returned by the widget, telling if it is selected
+        """
+        entity = contact_wid.data
+        self.host.mode_hint(C.MODE_INSERTION)
+        self._emit("click", entity)
+
+    def on_notification(self, entity, notif, profile):
+        notifs = list(self.host.get_notifs(C.ENTITY_ALL, profile=self.profile))
+        if notifs:
+            self.title_dynamic = "({})".format(len(notifs))
+        else:
+            self.title_dynamic = None
+        self.host.redraw()  # FIXME: should not be necessary
+
+    # Methods to build the widget
+
+    def _build_entity_widget(
+        self,
+        entity,
+        keys=None,
+        use_bare_jid=False,
+        with_notifs=True,
+        with_show_attr=True,
+        markup_prepend=None,
+        markup_append=None,
+        special=False,
+    ):
+        """Build one contact markup data
+
+        @param entity (jid.JID): entity to build
+        @param keys (iterable): value to markup, in preferred order.
+            The first available key will be used.
+            If key starts with "cache_", it will be checked in cache,
+            else, getattr will be done on entity with the key (e.g. getattr(entity, 'node')).
+            If nothing full or keys is None, full entity is used.
+        @param use_bare_jid (bool): if True, use bare jid for selected comparisons
+        @param with_notifs (bool): if True, show notification count
+        @param with_show_attr (bool): if True, show color corresponding to presence status
+        @param markup_prepend (list): markup to prepend to the generated one before building the widget
+        @param markup_append (list): markup to append to the generated one before building the widget
+        @param special (bool): True if entity is a special one
+        @return (list): markup data are expected by Urwid text widgets
+        """
+        markup = []
+        if use_bare_jid:
+            selected = {entity.bare for entity in self.contact_list._selected}
+        else:
+            selected = self.contact_list._selected
+        if keys is None:
+            entity_txt = entity
+        else:
+            cache = self.contact_list.getCache(entity)
+            for key in keys:
+                if key.startswith("cache_"):
+                    entity_txt = cache.get(key[6:])
+                else:
+                    entity_txt = getattr(entity, key)
+                if entity_txt:
+                    break
+            if not entity_txt:
+                entity_txt = entity
+
+        if with_show_attr:
+            show = self.contact_list.getCache(entity, C.PRESENCE_SHOW, default=None)
+            if show is None:
+                show = C.PRESENCE_UNAVAILABLE
+            show_icon, entity_attr = C.PRESENCE.get(show, ("", "default"))
+            markup.insert(0, "{} ".format(show_icon))
+        else:
+            entity_attr = "default"
+
+        notifs = list(
+            self.host.get_notifs(entity, exact_jid=special, profile=self.profile)
+        )
+        mentions = list(
+                self.host.get_notifs(entity.bare, C.NOTIFY_MENTION, profile=self.profile)
+            )
+        if notifs or mentions:
+            attr = 'cl_mention' if mentions else 'cl_notifs'
+            header = [(attr, "({})".format(len(notifs) + len(mentions))), " "]
+        else:
+            header = ""
+
+        markup.append((entity_attr, entity_txt))
+        if markup_prepend:
+            markup.insert(0, markup_prepend)
+        if markup_append:
+            markup.extend(markup_append)
+
+        widget = sat_widgets.SelectableText(
+            markup, selected=entity in selected, header=header
+        )
+        widget.data = entity
+        widget.comp = entity_txt.lower()  # value to use for sorting
+        urwid.connect_signal(
+            widget, "change", self._contact_clicked, user_args=[use_bare_jid]
+        )
+        return widget
+
+    def _build_entities(self, content, entities):
+        """Add entity representation in widget list
+
+        @param content: widget list, e.g. SimpleListWalker
+        @param entities (iterable): iterable of JID to display
+        """
+        if not entities:
+            return
+        widgets = []  # list of built widgets
+
+        for entity in entities:
+            if (
+                entity in self.contact_list._specials
+                or not self.contact_list.entity_visible(entity)
+            ):
+                continue
+            markup_extra = []
+            if self.contact_list.show_resources:
+                for resource in self.contact_list.getCache(entity, C.CONTACT_RESOURCES):
+                    resource_disp = (
+                        "resource_main"
+                        if resource
+                        == self.contact_list.getCache(entity, C.CONTACT_MAIN_RESOURCE)
+                        else "resource",
+                        "\n  " + resource,
+                    )
+                    markup_extra.append(resource_disp)
+                    if self.contact_list.show_status:
+                        status = self.contact_list.getCache(
+                            jid.JID("%s/%s" % (entity, resource)), "status", default=None
+                        )
+                        status_disp = ("status", "\n    " + status) if status else ""
+                        markup_extra.append(status_disp)
+
+            else:
+                if self.contact_list.show_status:
+                    status = self.contact_list.getCache(entity, "status", default=None)
+                    status_disp = ("status", "\n  " + status) if status else ""
+                    markup_extra.append(status_disp)
+            widget = self._build_entity_widget(
+                entity,
+                ("cache_nick", "cache_name", "node"),
+                use_bare_jid=True,
+                markup_append=markup_extra,
+            )
+            widgets.append(widget)
+
+        widgets.sort(key=lambda widget: widget.comp)
+
+        for widget in widgets:
+            content.append(widget)
+
+    def _build_specials(self, content):
+        """Build the special entities"""
+        specials = sorted(self.contact_list.get_specials())
+        current = None
+        for entity in specials:
+            if current is not None and current.bare == entity.bare:
+                # nested entity (e.g. MUC private conversations)
+                widget = self._build_entity_widget(
+                    entity, ("resource",), markup_prepend="  ", special=True
+                )
+            else:
+                # the special widgets
+                if entity.resource:
+                    widget = self._build_entity_widget(entity, ("resource",), special=True)
+                else:
+                    widget = self._build_entity_widget(
+                        entity,
+                        ("cache_nick", "cache_name", "node"),
+                        with_show_attr=False,
+                        special=True,
+                    )
+            content.append(widget)
+
+    def _build_list(self):
+        """Build the main contact list widget"""
+        content = urwid.SimpleListWalker([])
+
+        self._build_specials(content)
+        if self.contact_list._specials:
+            content.append(urwid.Divider("="))
+
+        groups = list(self.contact_list._groups)
+        groups.sort(key=lambda x: x.lower() if x else '')
+        for group in groups:
+            data = self.contact_list.get_group_data(group)
+            folded = data.get(C.GROUP_DATA_FOLDED, False)
+            jids = list(data["jids"])
+            if group is not None and (
+                self.contact_list.any_entity_visible(jids)
+                or self.contact_list.show_empty_groups
+            ):
+                header = "[-]" if not folded else "[+]"
+                widget = sat_widgets.ClickableText(group, header=header + " ")
+                content.append(widget)
+                urwid.connect_signal(widget, "click", self._group_clicked)
+            if not folded:
+                self._build_entities(content, jids)
+        not_in_roster = (
+            set(self.contact_list._cache)
+            .difference(self.contact_list._roster)
+            .difference(self.contact_list._specials)
+            .difference((self.contact_list.whoami.bare,))
+        )
+        if not_in_roster:
+            content.append(urwid.Divider("-"))
+            self._build_entities(content, not_in_roster)
+
+        return urwid.ListBox(content)
+
+
+quick_widgets.register(QuickContactList, ContactList)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/game_tarot.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,397 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+import urwid
+from urwid_satext import sat_widgets
+from libervia.frontends.tools.games import TarotCard
+from libervia.frontends.quick_frontend.quick_game_tarot import QuickTarotGame
+from libervia.frontends.primitivus import xmlui
+from libervia.frontends.primitivus.keys import action_key_map as a_key
+
+
+class CardDisplayer(urwid.Text):
+    """Show a card"""
+
+    signals = ["click"]
+
+    def __init__(self, card):
+        self.__selected = False
+        self.card = card
+        urwid.Text.__init__(self, card.get_attr_text())
+
+    def selectable(self):
+        return True
+
+    def keypress(self, size, key):
+        if key == a_key["CARD_SELECT"]:
+            self.select(not self.__selected)
+            self._emit("click")
+        return key
+
+    def mouse_event(self, size, event, button, x, y, focus):
+        if urwid.is_mouse_event(event) and button == 1:
+            self.select(not self.__selected)
+            self._emit("click")
+            return True
+
+        return False
+
+    def select(self, state=True):
+        self.__selected = state
+        attr, txt = self.card.get_attr_text()
+        if self.__selected:
+            attr += "_selected"
+        self.set_text((attr, txt))
+        self._invalidate()
+
+    def is_selected(self):
+        return self.__selected
+
+    def get_card(self):
+        return self.card
+
+    def render(self, size, focus=False):
+        canvas = urwid.CompositeCanvas(urwid.Text.render(self, size, focus))
+        if focus:
+            canvas.set_cursor((0, 0))
+        return canvas
+
+
+class Hand(urwid.WidgetWrap):
+    """Used to display several cards, and manage a hand"""
+
+    signals = ["click"]
+
+    def __init__(self, hand=[], selectable=False, on_click=None, user_data=None):
+        """@param hand: list of Card"""
+        self.__selectable = selectable
+        self.columns = urwid.Columns([], dividechars=1)
+        if on_click:
+            urwid.connect_signal(self, "click", on_click, user_data)
+        if hand:
+            self.update(hand)
+        urwid.WidgetWrap.__init__(self, self.columns)
+
+    def selectable(self):
+        return self.__selectable
+
+    def keypress(self, size, key):
+
+        if CardDisplayer in [wid.__class__ for wid in self.columns.widget_list]:
+            return self.columns.keypress(size, key)
+        else:
+            # No card displayed, we still have to manage the clicks
+            if key == a_key["CARD_SELECT"]:
+                self._emit("click", None)
+            return key
+
+    def get_selected(self):
+        """Return a list of selected cards"""
+        _selected = []
+        for wid in self.columns.widget_list:
+            if isinstance(wid, CardDisplayer) and wid.is_selected():
+                _selected.append(wid.get_card())
+        return _selected
+
+    def update(self, hand):
+        """Update the hand displayed in this widget
+        @param hand: list of Card"""
+        try:
+            del self.columns.widget_list[:]
+            del self.columns.column_types[:]
+        except IndexError:
+            pass
+        self.columns.contents.append((urwid.Text(""), ("weight", 1, False)))
+        for card in hand:
+            widget = CardDisplayer(card)
+            self.columns.widget_list.append(widget)
+            self.columns.column_types.append(("fixed", 3))
+            urwid.connect_signal(widget, "click", self.__on_click)
+        self.columns.contents.append((urwid.Text(""), ("weight", 1, False)))
+        self.columns.focus_position = 1
+
+    def __on_click(self, card_wid):
+        self._emit("click", card_wid)
+
+
+class Card(TarotCard):
+    """This class is used to represent a card, logically
+    and give a text representation with attributes"""
+
+    SIZE = 3  # size of a displayed card
+
+    def __init__(self, suit, value):
+        """@param file: path of the PNG file"""
+        TarotCard.__init__(self, (suit, value))
+
+    def get_attr_text(self):
+        """return text representation of the card with attributes"""
+        try:
+            value = "%02i" % int(self.value)
+        except ValueError:
+            value = self.value[0].upper() + self.value[1]
+        if self.suit == "atout":
+            if self.value == "excuse":
+                suit = "c"
+            else:
+                suit = "A"
+            color = "neutral"
+        elif self.suit == "pique":
+            suit = "♠"
+            color = "black"
+        elif self.suit == "trefle":
+            suit = "♣"
+            color = "black"
+        elif self.suit == "coeur":
+            suit = "♥"
+            color = "red"
+        elif self.suit == "carreau":
+            suit = "♦"
+            color = "red"
+        if self.bout:
+            color = "special"
+        return ("card_%s" % color, "%s%s" % (value, suit))
+
+    def get_widget(self):
+        """Return a widget representing the card"""
+        return CardDisplayer(self)
+
+
+class Table(urwid.FlowWidget):
+    """Represent the cards currently on the table"""
+
+    def __init__(self):
+        self.top = self.left = self.bottom = self.right = None
+
+    def put_card(self, location, card):
+        """Put a card on the table
+        @param location: where to put the card (top, left, bottom or right)
+        @param card: Card to play or None"""
+        assert location in ["top", "left", "bottom", "right"]
+        assert isinstance(card, Card) or card == None
+        if [getattr(self, place) for place in ["top", "left", "bottom", "right"]].count(
+            None
+        ) == 0:
+            # If the table is full of card, we remove them
+            self.top = self.left = self.bottom = self.right = None
+        setattr(self, location, card)
+        self._invalidate()
+
+    def rows(self, size, focus=False):
+        return self.display_widget(size, focus).rows(size, focus)
+
+    def render(self, size, focus=False):
+        return self.display_widget(size, focus).render(size, focus)
+
+    def display_widget(self, size, focus):
+        cards = {}
+        max_col, = size
+        separator = " - "
+        margin = max((max_col - Card.SIZE) / 2, 0) * " "
+        margin_center = max((max_col - Card.SIZE * 2 - len(separator)) / 2, 0) * " "
+        for location in ["top", "left", "bottom", "right"]:
+            card = getattr(self, location)
+            cards[location] = card.get_attr_text() if card else Card.SIZE * " "
+        render_wid = [
+            urwid.Text([margin, cards["top"]]),
+            urwid.Text([margin_center, cards["left"], separator, cards["right"]]),
+            urwid.Text([margin, cards["bottom"]]),
+        ]
+        return urwid.Pile(render_wid)
+
+
+class TarotGame(QuickTarotGame, urwid.WidgetWrap):
+    """Widget for card games"""
+
+    def __init__(self, parent, referee, players):
+        QuickTarotGame.__init__(self, parent, referee, players)
+        self.load_cards()
+        self.top = urwid.Pile([urwid.Padding(urwid.Text(self.top_nick), "center")])
+        # self.parent.host.debug()
+        self.table = Table()
+        self.center = urwid.Columns(
+            [
+                ("fixed", len(self.left_nick), urwid.Filler(urwid.Text(self.left_nick))),
+                urwid.Filler(self.table),
+                (
+                    "fixed",
+                    len(self.right_nick),
+                    urwid.Filler(urwid.Text(self.right_nick)),
+                ),
+            ]
+        )
+        """urwid.Pile([urwid.Padding(self.top_card_wid,'center'),
+                             urwid.Columns([('fixed',len(self.left_nick),urwid.Text(self.left_nick)),
+                                            urwid.Padding(self.center_cards_wid,'center'),
+                                            ('fixed',len(self.right_nick),urwid.Text(self.right_nick))
+                                           ]),
+                             urwid.Padding(self.bottom_card_wid,'center')
+                             ])"""
+        self.hand_wid = Hand(selectable=True, on_click=self.on_click)
+        self.main_frame = urwid.Frame(
+            self.center, header=self.top, footer=self.hand_wid, focus_part="footer"
+        )
+        urwid.WidgetWrap.__init__(self, self.main_frame)
+        self.parent.host.bridge.tarot_game_ready(
+            self.player_nick, referee, self.parent.profile
+        )
+
+    def load_cards(self):
+        """Load all the cards in memory"""
+        QuickTarotGame.load_cards(self)
+        for value in list(map(str, list(range(1, 22)))) + ["excuse"]:
+            card = Card("atout", value)
+            self.cards[card.suit, card.value] = card
+            self.deck.append(card)
+        for suit in ["pique", "coeur", "carreau", "trefle"]:
+            for value in list(map(str, list(range(1, 11)))) + ["valet", "cavalier", "dame", "roi"]:
+                card = Card(suit, value)
+                self.cards[card.suit, card.value] = card
+                self.deck.append(card)
+
+    def tarot_game_new_handler(self, hand):
+        """Start a new game, with given hand"""
+        if hand is []:  # reset the display after the scores have been showed
+            self.reset_round()
+            for location in ["top", "left", "bottom", "right"]:
+                self.table.put_card(location, None)
+            self.parent.host.redraw()
+            self.parent.host.bridge.tarot_game_ready(
+                self.player_nick, self.referee, self.parent.profile
+            )
+            return
+        QuickTarotGame.tarot_game_new_handler(self, hand)
+        self.hand_wid.update(self.hand)
+        self.parent.host.redraw()
+
+    def tarot_game_choose_contrat_handler(self, xml_data):
+        """Called when the player has to select his contrat
+        @param xml_data: SàT xml representation of the form"""
+        form = xmlui.create(
+            self.parent.host,
+            xml_data,
+            title=_("Please choose your contrat"),
+            flags=["NO_CANCEL"],
+            profile=self.parent.profile,
+        )
+        form.show(valign="top")
+
+    def tarot_game_show_cards_handler(self, game_stage, cards, data):
+        """Display cards in the middle of the game (to show for e.g. chien ou poignée)"""
+        QuickTarotGame.tarot_game_show_cards_handler(self, game_stage, cards, data)
+        self.center.widget_list[1] = urwid.Filler(Hand(self.to_show))
+        self.parent.host.redraw()
+
+    def tarot_game_your_turn_handler(self):
+        QuickTarotGame.tarot_game_your_turn_handler(self)
+
+    def tarot_game_score_handler(self, xml_data, winners, loosers):
+        """Called when the round is over, display the scores
+        @param xml_data: SàT xml representation of the form"""
+        if not winners and not loosers:
+            title = _("Draw game")
+        else:
+            title = _("You win \o/") if self.player_nick in winners else _("You loose :(")
+        form = xmlui.create(
+            self.parent.host,
+            xml_data,
+            title=title,
+            flags=["NO_CANCEL"],
+            profile=self.parent.profile,
+        )
+        form.show()
+
+    def tarot_game_invalid_cards_handler(self, phase, played_cards, invalid_cards):
+        """Invalid cards have been played
+        @param phase: phase of the game
+        @param played_cards: all the cards played
+        @param invalid_cards: cards which are invalid"""
+        QuickTarotGame.tarot_game_invalid_cards_handler(
+            self, phase, played_cards, invalid_cards
+        )
+        self.hand_wid.update(self.hand)
+        if self._autoplay == None:  # No dialog if there is autoplay
+            self.parent.host.bar_notify(_("Cards played are invalid !"))
+        self.parent.host.redraw()
+
+    def tarot_game_cards_played_handler(self, player, cards):
+        """A card has been played by player"""
+        QuickTarotGame.tarot_game_cards_played_handler(self, player, cards)
+        self.table.put_card(self.get_player_location(player), self.played[player])
+        self._checkState()
+        self.parent.host.redraw()
+
+    def _checkState(self):
+        if isinstance(
+            self.center.widget_list[1].original_widget, Hand
+        ):  # if we have a hand displayed
+            self.center.widget_list[1] = urwid.Filler(
+                self.table
+            )  # we show again the table
+            if self.state == "chien":
+                self.to_show = []
+                self.state = "wait"
+            elif self.state == "wait_for_ecart":
+                self.state = "ecart"
+                self.hand.extend(self.to_show)
+                self.hand.sort()
+                self.to_show = []
+                self.hand_wid.update(self.hand)
+
+    ##EVENTS##
+    def on_click(self, hand, card_wid):
+        """Called when user do an action on the hand"""
+        if not self.state in ["play", "ecart", "wait_for_ecart"]:
+            # it's not our turn, we ignore the click
+            card_wid.select(False)
+            return
+        self._checkState()
+        if self.state == "ecart":
+            if len(self.hand_wid.get_selected()) == 6:
+                pop_up_widget = sat_widgets.ConfirmDialog(
+                    _("Do you put these cards in chien ?"),
+                    yes_cb=self.on_ecart_done,
+                    no_cb=self.parent.host.remove_pop_up,
+                )
+                self.parent.host.show_pop_up(pop_up_widget)
+        elif self.state == "play":
+            card = card_wid.get_card()
+            self.parent.host.bridge.tarot_game_play_cards(
+                self.player_nick,
+                self.referee,
+                [(card.suit, card.value)],
+                self.parent.profile,
+            )
+            self.hand.remove(card)
+            self.hand_wid.update(self.hand)
+            self.state = "wait"
+
+    def on_ecart_done(self, button):
+        """Called when player has finished his écart"""
+        ecart = []
+        for card in self.hand_wid.get_selected():
+            ecart.append((card.suit, card.value))
+            self.hand.remove(card)
+        self.hand_wid.update(self.hand)
+        self.parent.host.bridge.tarot_game_play_cards(
+            self.player_nick, self.referee, ecart, self.parent.profile
+        )
+        self.state = "wait"
+        self.parent.host.remove_pop_up()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/keys.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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/>.
+
+"""This file manage the action <=> key map"""
+
+from urwid_satext.keys import action_key_map
+
+
+action_key_map.update(
+    {
+        # Edit bar
+        ("edit", "MODE_INSERTION"): "i",
+        ("edit", "MODE_COMMAND"): ":",
+        ("edit", "HISTORY_PREV"): "up",
+        ("edit", "HISTORY_NEXT"): "down",
+        # global
+        ("global", "MENU_HIDE"): "meta m",
+        ("global", "NOTIFICATION_NEXT"): "ctrl n",
+        ("global", "OVERLAY_HIDE"): "ctrl s",
+        ("global", "DEBUG"): "ctrl d",
+        ("global", "CONTACTS_HIDE"): "f2",
+        (
+            "global",
+            "REFRESH_SCREEN",
+        ): "ctrl l",  # ctrl l is used by Urwid to refresh screen
+        # global menu
+        ("menu_global", "APP_QUIT"): "ctrl x",
+        ("menu_global", "ROOM_JOIN"): "meta j",
+        # primitivus widgets
+        ("primitivus_widget", "DECORATION_HIDE"): "meta l",
+        # contact list
+        ("contact_list", "STATUS_HIDE"): "meta s",
+        ("contact_list", "DISCONNECTED_HIDE"): "meta d",
+        ("contact_list", "RESOURCES_HIDE"): "meta r",
+        # chat panel
+        ("chat_panel", "OCCUPANTS_HIDE"): "meta p",
+        ("chat_panel", "TIMESTAMP_HIDE"): "meta t",
+        ("chat_panel", "SHORT_NICKNAME"): "meta n",
+        ("chat_panel", "SUBJECT_SWITCH"): "meta s",
+        ("chat_panel", "GOTO_BOTTOM"): "G",
+        # card game
+        ("card_game", "CARD_SELECT"): " ",
+        # focus
+        ("focus", "FOCUS_EXTRA"): "ctrl f",
+    }
+)
+
+
+action_key_map.set_close_namespaces(tuple(), ("global", "focus", "menu_global"))
+action_key_map.check_namespaces()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/notify.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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/>.
+
+import dbus
+
+
+class Notify(object):
+    """Used to send notification and detect if we have focus"""
+
+    def __init__(self):
+
+        # X11 stuff
+        self.display = None
+        self.X11_id = -1
+
+        try:
+            from Xlib import display as X_display
+
+            self.display = X_display.Display()
+            self.X11_id = self.get_focus()
+        except:
+            pass
+
+        # Now we try to connect to Freedesktop D-Bus API
+        try:
+            bus = dbus.SessionBus()
+            db_object = bus.get_object(
+                "org.freedesktop.Notifications",
+                "/org/freedesktop/Notifications",
+                follow_name_owner_changes=True,
+            )
+            self.freedesktop_int = dbus.Interface(
+                db_object, dbus_interface="org.freedesktop.Notifications"
+            )
+        except:
+            self.freedesktop_int = None
+
+    def get_focus(self):
+        if not self.display:
+            return 0
+        return self.display.get_input_focus().focus.id
+
+    def has_focus(self):
+        return (self.get_focus() == self.X11_id) if self.display else True
+
+    def use_x11(self):
+        return bool(self.display)
+
+    def send_notification(self, summ_mess, body_mess=""):
+        """Send notification to the user if possible"""
+        # TODO: check options before sending notifications
+        if self.freedesktop_int:
+            self.send_fd_notification(summ_mess, body_mess)
+
+    def send_fd_notification(self, summ_mess, body_mess=""):
+        """Send notification with the FreeDesktop D-Bus API"""
+        if self.freedesktop_int:
+            app_name = "Primitivus"
+            replaces_id = 0
+            app_icon = ""
+            summary = summ_mess
+            body = body_mess
+            actions = dbus.Array(signature="s")
+            hints = dbus.Dictionary(signature="sv")
+            expire_timeout = -1
+
+            self.freedesktop_int.Notify(
+                app_name,
+                replaces_id,
+                app_icon,
+                summary,
+                body,
+                actions,
+                hints,
+                expire_timeout,
+            )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/profile_manager.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
+
+log = logging.getLogger(__name__)
+from libervia.frontends.quick_frontend.quick_profile_manager import QuickProfileManager
+from libervia.frontends.primitivus.constants import Const as C
+from libervia.frontends.primitivus.keys import action_key_map as a_key
+from urwid_satext import sat_widgets
+import urwid
+
+
+class ProfileManager(QuickProfileManager, urwid.WidgetWrap):
+    def __init__(self, host, autoconnect=None):
+        QuickProfileManager.__init__(self, host, autoconnect)
+
+        # login & password box must be created before list because of on_profile_change
+        self.login_wid = sat_widgets.AdvancedEdit(_("Login:"), align="center")
+        self.pass_wid = sat_widgets.Password(_("Password:"), align="center")
+
+        style = ["no_first_select"]
+        profiles = host.bridge.profiles_list_get()
+        profiles.sort()
+        self.list_profile = sat_widgets.List(
+            profiles, style=style, align="center", on_change=self.on_profile_change
+        )
+
+        # new & delete buttons
+        buttons = [
+            urwid.Button(_("New"), self.on_new_profile),
+            urwid.Button(_("Delete"), self.on_delete_profile),
+        ]
+        buttons_flow = urwid.GridFlow(
+            buttons,
+            max([len(button.get_label()) for button in buttons]) + 4,
+            1,
+            1,
+            "center",
+        )
+
+        # second part: login information:
+        divider = urwid.Divider("-")
+
+        # connect button
+        connect_button = sat_widgets.CustomButton(
+            _("Connect"), self.on_connect_profiles, align="center"
+        )
+
+        # we now build the widget
+        list_walker = urwid.SimpleFocusListWalker(
+            [
+                buttons_flow,
+                self.list_profile,
+                divider,
+                self.login_wid,
+                self.pass_wid,
+                connect_button,
+            ]
+        )
+        frame_body = urwid.ListBox(list_walker)
+        frame = urwid.Frame(
+            frame_body,
+            urwid.AttrMap(urwid.Text(_("Profile Manager"), align="center"), "title"),
+        )
+        self.main_widget = urwid.LineBox(frame)
+        urwid.WidgetWrap.__init__(self, self.main_widget)
+
+        self.go(autoconnect)
+
+    def keypress(self, size, key):
+        if key == a_key["APP_QUIT"]:
+            self.host.on_exit()
+            raise urwid.ExitMainLoop()
+        elif key in (a_key["FOCUS_UP"], a_key["FOCUS_DOWN"]):
+            focus_diff = 1 if key == a_key["FOCUS_DOWN"] else -1
+            list_box = self.main_widget.base_widget.body
+            current_focus = list_box.body.get_focus()[1]
+            if current_focus is None:
+                return
+            while True:
+                current_focus += focus_diff
+                if current_focus < 0 or current_focus >= len(list_box.body):
+                    break
+                if list_box.body[current_focus].selectable():
+                    list_box.set_focus(
+                        current_focus, "above" if focus_diff == 1 else "below"
+                    )
+                    list_box._invalidate()
+                    return
+        return super(ProfileManager, self).keypress(size, key)
+
+    def cancel_dialog(self, button):
+        self.host.remove_pop_up()
+
+    def new_profile(self, button, edit):
+        """Create the profile"""
+        name = edit.get_edit_text()
+        self.host.bridge.profile_create(
+            name,
+            callback=lambda: self.new_profile_created(name),
+            errback=self.profile_creation_failure,
+        )
+
+    def new_profile_created(self, profile):
+        # new profile will be selected, and a selected profile assume the session is started
+        self.host.bridge.profile_start_session(
+            "",
+            profile,
+            callback=lambda __: self.new_profile_session_started(profile),
+            errback=self.profile_creation_failure,
+        )
+
+    def new_profile_session_started(self, profile):
+        self.host.remove_pop_up()
+        self.refill_profiles()
+        self.list_profile.select_value(profile)
+        self.current.profile = profile
+        self.get_connection_params(profile)
+        self.host.redraw()
+
+    def profile_creation_failure(self, reason):
+        self.host.remove_pop_up()
+        message = self._get_error_message(reason)
+        self.host.alert(_("Can't create profile"), message)
+
+    def delete_profile(self, button):
+        self._delete_profile()
+        self.host.remove_pop_up()
+
+    def on_new_profile(self, e):
+        pop_up_widget = sat_widgets.InputDialog(
+            _("New profile"),
+            _("Please enter a new profile name"),
+            cancel_cb=self.cancel_dialog,
+            ok_cb=self.new_profile,
+        )
+        self.host.show_pop_up(pop_up_widget)
+
+    def on_delete_profile(self, e):
+        if self.current.profile:
+            pop_up_widget = sat_widgets.ConfirmDialog(
+                _("Are you sure you want to delete the profile {} ?").format(
+                    self.current.profile
+                ),
+                no_cb=self.cancel_dialog,
+                yes_cb=self.delete_profile,
+            )
+            self.host.show_pop_up(pop_up_widget)
+
+    def on_connect_profiles(self, button):
+        """Connect the profiles and start the main widget
+
+        @param button: the connect button
+        """
+        self._on_connect_profiles()
+
+    def reset_fields(self):
+        """Set profile to None, and reset fields"""
+        super(ProfileManager, self).reset_fields()
+        self.list_profile.unselect_all(invisible=True)
+
+    def set_profiles(self, profiles):
+        """Update the list of profiles"""
+        self.list_profile.change_values(profiles)
+        self.host.redraw()
+
+    def get_profiles(self):
+        return self.list_profile.get_selected_values()
+
+    def get_jid(self):
+        return self.login_wid.get_edit_text()
+
+    def getPassword(self):
+        return self.pass_wid.get_edit_text()
+
+    def set_jid(self, jid_):
+        self.login_wid.set_edit_text(jid_)
+        self.current.login = jid_
+        self.host.redraw()  # FIXME: redraw should be avoided
+
+    def set_password(self, password):
+        self.pass_wid.set_edit_text(password)
+        self.current.password = password
+        self.host.redraw()
+
+    def on_profile_change(self, list_wid, widget=None, selected=None):
+        """This is called when a profile is selected in the profile list.
+
+        @param list_wid: the List widget who sent the event
+        """
+        self.update_connection_params()
+        focused = list_wid.focus
+        selected = focused.get_state() if focused is not None else False
+        if not selected:  # profile was just unselected
+            return
+        focused.set_state(
+            False, invisible=True
+        )  # we don't want the widget to be selected until we are sure we can access it
+
+        def authenticate_cb(data, cb_id, profile):
+            if C.bool(data.pop("validated", C.BOOL_FALSE)):
+                self.current.profile = profile
+                focused.set_state(True, invisible=True)
+                self.get_connection_params(profile)
+                self.host.redraw()
+            self.host.action_manager(data, callback=authenticate_cb, profile=profile)
+
+        self.host.action_launch(
+            C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, profile=focused.text
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/progress.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+import urwid
+from urwid_satext import sat_widgets
+from libervia.frontends.quick_frontend import quick_widgets
+
+
+class Progress(urwid.WidgetWrap, quick_widgets.QuickWidget):
+    PROFILES_ALLOW_NONE = True
+
+    def __init__(self, host, target, profiles):
+        assert target is None and profiles is None
+        quick_widgets.QuickWidget.__init__(self, host, target)
+        self.host = host
+        self.progress_list = urwid.SimpleListWalker([])
+        self.progress_dict = {}
+        listbox = urwid.ListBox(self.progress_list)
+        buttons = []
+        buttons.append(sat_widgets.CustomButton(_("Clear progress list"), self._on_clear))
+        max_len = max([button.get_size() for button in buttons])
+        buttons_wid = urwid.GridFlow(buttons, max_len, 1, 0, "center")
+        main_wid = sat_widgets.FocusFrame(listbox, footer=buttons_wid)
+        urwid.WidgetWrap.__init__(self, main_wid)
+
+    def add(self, progress_id, message, profile):
+        mess_wid = urwid.Text(message)
+        progr_wid = urwid.ProgressBar("progress_normal", "progress_complete")
+        column = urwid.Columns([mess_wid, progr_wid])
+        self.progress_dict[(progress_id, profile)] = {
+            "full": column,
+            "progress": progr_wid,
+            "state": "init",
+        }
+        self.progress_list.append(column)
+        self.progress_cb(self.host.loop, (progress_id, message, profile))
+
+    def progress_cb(self, loop, data):
+        progress_id, message, profile = data
+        data = self.host.bridge.progress_get(progress_id, profile)
+        pbar = self.progress_dict[(progress_id, profile)]["progress"]
+        if data:
+            if self.progress_dict[(progress_id, profile)]["state"] == "init":
+                # first answer, we must construct the bar
+                self.progress_dict[(progress_id, profile)]["state"] = "progress"
+                pbar.done = float(data["size"])
+
+            pbar.set_completion(float(data["position"]))
+            self.update_not_bar()
+        else:
+            if self.progress_dict[(progress_id, profile)]["state"] == "progress":
+                self.progress_dict[(progress_id, profile)]["state"] = "done"
+                pbar.set_completion(pbar.done)
+                self.update_not_bar()
+                return
+
+        loop.set_alarm_in(0.2, self.progress_cb, (progress_id, message, profile))
+
+    def _remove_bar(self, progress_id, profile):
+        wid = self.progress_dict[(progress_id, profile)]["full"]
+        self.progress_list.remove(wid)
+        del (self.progress_dict[(progress_id, profile)])
+
+    def _on_clear(self, button):
+        to_remove = []
+        for progress_id, profile in self.progress_dict:
+            if self.progress_dict[(progress_id, profile)]["state"] == "done":
+                to_remove.append((progress_id, profile))
+        for progress_id, profile in to_remove:
+            self._remove_bar(progress_id, profile)
+        self.update_not_bar()
+
+    def update_not_bar(self):
+        if not self.progress_dict:
+            self.host.set_progress(None)
+            return
+        progress = 0
+        nb_bars = 0
+        for progress_id, profile in self.progress_dict:
+            pbar = self.progress_dict[(progress_id, profile)]["progress"]
+            progress += pbar.current / pbar.done * 100
+            nb_bars += 1
+        av_progress = progress / float(nb_bars)
+        self.host.set_progress(av_progress)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/status.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 libervia.backend.core.i18n import _
+import urwid
+from urwid_satext import sat_widgets
+from libervia.frontends.quick_frontend.constants import Const as commonConst
+from libervia.frontends.primitivus.constants import Const as C
+
+
+class StatusBar(urwid.Columns):
+    def __init__(self, host):
+        self.host = host
+        self.presence = sat_widgets.ClickableText("")
+        status_prefix = urwid.Text("[")
+        status_suffix = urwid.Text("]")
+        self.status = sat_widgets.ClickableText("")
+        self.set_presence_status(C.PRESENCE_UNAVAILABLE, "")
+        urwid.Columns.__init__(
+            self,
+            [
+                ("weight", 1, self.presence),
+                ("weight", 1, status_prefix),
+                ("weight", 9, self.status),
+                ("weight", 1, status_suffix),
+            ],
+        )
+        urwid.connect_signal(self.presence, "click", self.on_presence_click)
+        urwid.connect_signal(self.status, "click", self.on_status_click)
+
+    def on_presence_click(self, sender=None):
+        if not self.host.bridge.is_connected(
+            self.host.current_profile
+        ):  # FIXME: manage multi-profiles
+            return
+        options = [commonConst.PRESENCE[presence] for presence in commonConst.PRESENCE]
+        list_widget = sat_widgets.GenericList(
+            options=options, option_type=sat_widgets.ClickableText, on_click=self.on_change
+        )
+        decorated = sat_widgets.LabelLine(
+            list_widget, sat_widgets.SurroundedText(_("Set your presence"))
+        )
+        self.host.show_pop_up(decorated)
+
+    def on_status_click(self, sender=None):
+        if not self.host.bridge.is_connected(
+            self.host.current_profile
+        ):  # FIXME: manage multi-profiles
+            return
+        pop_up_widget = sat_widgets.InputDialog(
+            _("Set your status"),
+            _("New status"),
+            default_txt=self.status.get_text(),
+            cancel_cb=lambda _: self.host.remove_pop_up(),
+            ok_cb=self.on_change,
+        )
+        self.host.show_pop_up(pop_up_widget)
+
+    def on_change(self, sender=None, user_data=None):
+        new_value = user_data.get_text()
+        previous = (
+            [key for key in C.PRESENCE if C.PRESENCE[key][0] == self.presence.get_text()][
+                0
+            ],
+            self.status.get_text(),
+        )
+        if isinstance(user_data, sat_widgets.ClickableText):
+            new = (
+                [
+                    key
+                    for key in commonConst.PRESENCE
+                    if commonConst.PRESENCE[key] == new_value
+                ][0],
+                previous[1],
+            )
+        elif isinstance(user_data, sat_widgets.AdvancedEdit):
+            new = (previous[0], new_value[0])
+        if new != previous:
+            statuses = {
+                C.PRESENCE_STATUSES_DEFAULT: new[1]
+            }  # FIXME: manage multilingual statuses
+            for (
+                profile
+            ) in (
+                self.host.profiles
+            ):  # FIXME: for now all the profiles share the same status
+                self.host.bridge.presence_set(
+                    show=new[0], statuses=statuses, profile_key=profile
+                )
+            self.set_presence_status(new[0], new[1])
+        self.host.remove_pop_up()
+
+    def set_presence_status(self, show, status):
+        show_icon, show_attr = C.PRESENCE.get(show)
+        self.presence.set_text(("show_normal", show_icon))
+        if status is not None:
+            self.status.set_text((show_attr, status))
+        self.host.redraw()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/widget.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core import log as logging
+
+log = logging.getLogger(__name__)
+import urwid
+from urwid_satext import sat_widgets
+from libervia.frontends.primitivus.keys import action_key_map as a_key
+
+
+class PrimitivusWidget(urwid.WidgetWrap):
+    """Base widget for Primitivus"""
+
+    def __init__(self, w, title=""):
+        self._title = title
+        self._title_dynamic = None
+        self._original_widget = w
+        urwid.WidgetWrap.__init__(self, self._get_decoration(w))
+
+    @property
+    def title(self):
+        """Text shown in title bar of the widget"""
+
+        # profiles currently managed by frontend
+        try:
+            all_profiles = self.host.profiles
+        except AttributeError:
+            all_profiles = []
+
+        # profiles managed by the widget
+        try:
+            profiles = self.profiles
+        except AttributeError:
+            try:
+                profiles = [self.profile]
+            except AttributeError:
+                profiles = []
+
+        title_elts = []
+        if self._title:
+            title_elts.append(self._title)
+        if self._title_dynamic:
+            title_elts.append(self._title_dynamic)
+        if len(all_profiles) > 1 and profiles:
+            title_elts.append("[{}]".format(", ".join(profiles)))
+        return sat_widgets.SurroundedText(" ".join(title_elts))
+
+    @title.setter
+    def title(self, value):
+        self._title = value
+        if self.decoration_visible:
+            self.show_decoration()
+
+    @property
+    def title_dynamic(self):
+        """Dynamic part of title"""
+        return self._title_dynamic
+
+    @title_dynamic.setter
+    def title_dynamic(self, value):
+        self._title_dynamic = value
+        if self.decoration_visible:
+            self.show_decoration()
+
+    @property
+    def decoration_visible(self):
+        """True if the decoration is visible"""
+        return isinstance(self._w, sat_widgets.LabelLine)
+
+    def keypress(self, size, key):
+        if key == a_key["DECORATION_HIDE"]:  # user wants to (un)hide widget decoration
+            show = not self.decoration_visible
+            self.show_decoration(show)
+        else:
+            return super(PrimitivusWidget, self).keypress(size, key)
+
+    def _get_decoration(self, widget):
+        return sat_widgets.LabelLine(widget, self.title)
+
+    def show_decoration(self, show=True):
+        """Show/Hide the decoration around the window"""
+        self._w = (
+            self._get_decoration(self._original_widget) if show else self._original_widget
+        )
+
+    def get_menu(self):
+        raise NotImplementedError
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/primitivus/xmlui.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,528 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+import urwid
+import copy
+from libervia.backend.core import exceptions
+from urwid_satext import sat_widgets
+from urwid_satext import files_management
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.frontends.primitivus.constants import Const as C
+from libervia.frontends.primitivus.widget import PrimitivusWidget
+from libervia.frontends.tools import xmlui
+
+
+class PrimitivusEvents(object):
+    """ Used to manage change event of Primitivus widgets """
+
+    def _event_callback(self, ctrl, *args, **kwargs):
+        """" Call xmlui callback and ignore any extra argument """
+        args[-1](ctrl)
+
+    def _xmlui_on_change(self, callback):
+        """ Call callback with widget as only argument """
+        urwid.connect_signal(self, "change", self._event_callback, callback)
+
+
+class PrimitivusEmptyWidget(xmlui.EmptyWidget, urwid.Text):
+    def __init__(self, _xmlui_parent):
+        urwid.Text.__init__(self, "")
+
+
+class PrimitivusTextWidget(xmlui.TextWidget, urwid.Text):
+    def __init__(self, _xmlui_parent, value, read_only=False):
+        urwid.Text.__init__(self, value)
+
+
+class PrimitivusLabelWidget(xmlui.LabelWidget, PrimitivusTextWidget):
+    def __init__(self, _xmlui_parent, value):
+        super(PrimitivusLabelWidget, self).__init__(_xmlui_parent, value + ": ")
+
+
+class PrimitivusJidWidget(xmlui.JidWidget, PrimitivusTextWidget):
+    pass
+
+
+class PrimitivusDividerWidget(xmlui.DividerWidget, urwid.Divider):
+    def __init__(self, _xmlui_parent, style="line"):
+        if style == "line":
+            div_char = "─"
+        elif style == "dot":
+            div_char = "·"
+        elif style == "dash":
+            div_char = "-"
+        elif style == "plain":
+            div_char = "█"
+        elif style == "blank":
+            div_char = " "
+        else:
+            log.warning(_("Unknown div_char"))
+            div_char = "─"
+
+        urwid.Divider.__init__(self, div_char)
+
+
+class PrimitivusStringWidget(
+    xmlui.StringWidget, sat_widgets.AdvancedEdit, PrimitivusEvents
+):
+    def __init__(self, _xmlui_parent, value, read_only=False):
+        sat_widgets.AdvancedEdit.__init__(self, edit_text=value)
+        self.read_only = read_only
+
+    def selectable(self):
+        if self.read_only:
+            return False
+        return super(PrimitivusStringWidget, self).selectable()
+
+    def _xmlui_set_value(self, value):
+        self.set_edit_text(value)
+
+    def _xmlui_get_value(self):
+        return self.get_edit_text()
+
+
+class PrimitivusJidInputWidget(xmlui.JidInputWidget, PrimitivusStringWidget):
+    pass
+
+
+class PrimitivusPasswordWidget(
+    xmlui.PasswordWidget, sat_widgets.Password, PrimitivusEvents
+):
+    def __init__(self, _xmlui_parent, value, read_only=False):
+        sat_widgets.Password.__init__(self, edit_text=value)
+        self.read_only = read_only
+
+    def selectable(self):
+        if self.read_only:
+            return False
+        return super(PrimitivusPasswordWidget, self).selectable()
+
+    def _xmlui_set_value(self, value):
+        self.set_edit_text(value)
+
+    def _xmlui_get_value(self):
+        return self.get_edit_text()
+
+
+class PrimitivusTextBoxWidget(
+    xmlui.TextBoxWidget, sat_widgets.AdvancedEdit, PrimitivusEvents
+):
+    def __init__(self, _xmlui_parent, value, read_only=False):
+        sat_widgets.AdvancedEdit.__init__(self, edit_text=value, multiline=True)
+        self.read_only = read_only
+
+    def selectable(self):
+        if self.read_only:
+            return False
+        return super(PrimitivusTextBoxWidget, self).selectable()
+
+    def _xmlui_set_value(self, value):
+        self.set_edit_text(value)
+
+    def _xmlui_get_value(self):
+        return self.get_edit_text()
+
+
+class PrimitivusBoolWidget(xmlui.BoolWidget, urwid.CheckBox, PrimitivusEvents):
+    def __init__(self, _xmlui_parent, state, read_only=False):
+        urwid.CheckBox.__init__(self, "", state=state)
+        self.read_only = read_only
+
+    def selectable(self):
+        if self.read_only:
+            return False
+        return super(PrimitivusBoolWidget, self).selectable()
+
+    def _xmlui_set_value(self, value):
+        self.set_state(value == "true")
+
+    def _xmlui_get_value(self):
+        return C.BOOL_TRUE if self.get_state() else C.BOOL_FALSE
+
+
+class PrimitivusIntWidget(xmlui.IntWidget, sat_widgets.AdvancedEdit, PrimitivusEvents):
+    def __init__(self, _xmlui_parent, value, read_only=False):
+        sat_widgets.AdvancedEdit.__init__(self, edit_text=value)
+        self.read_only = read_only
+
+    def selectable(self):
+        if self.read_only:
+            return False
+        return super(PrimitivusIntWidget, self).selectable()
+
+    def _xmlui_set_value(self, value):
+        self.set_edit_text(value)
+
+    def _xmlui_get_value(self):
+        return self.get_edit_text()
+
+
+class PrimitivusButtonWidget(
+    xmlui.ButtonWidget, sat_widgets.CustomButton, PrimitivusEvents
+):
+    def __init__(self, _xmlui_parent, value, click_callback):
+        sat_widgets.CustomButton.__init__(self, value, on_press=click_callback)
+
+    def _xmlui_on_click(self, callback):
+        urwid.connect_signal(self, "click", callback)
+
+
+class PrimitivusListWidget(xmlui.ListWidget, sat_widgets.List, PrimitivusEvents):
+    def __init__(self, _xmlui_parent, options, selected, flags):
+        sat_widgets.List.__init__(self, options=options, style=flags)
+        self._xmlui_select_values(selected)
+
+    def _xmlui_select_value(self, value):
+        return self.select_value(value)
+
+    def _xmlui_select_values(self, values):
+        return self.select_values(values)
+
+    def _xmlui_get_selected_values(self):
+        return [option.value for option in self.get_selected_values()]
+
+    def _xmlui_add_values(self, values, select=True):
+        current_values = self.get_all_values()
+        new_values = copy.deepcopy(current_values)
+        for value in values:
+            if value not in current_values:
+                new_values.append(value)
+        if select:
+            selected = self._xmlui_get_selected_values()
+        self.change_values(new_values)
+        if select:
+            for value in values:
+                if value not in selected:
+                    selected.append(value)
+            self._xmlui_select_values(selected)
+
+
+class PrimitivusJidsListWidget(xmlui.ListWidget, sat_widgets.List, PrimitivusEvents):
+    def __init__(self, _xmlui_parent, jids, styles):
+        sat_widgets.List.__init__(
+            self,
+            options=jids + [""],  # the empty field is here to add new jids if needed
+            option_type=lambda txt, align: sat_widgets.AdvancedEdit(
+                edit_text=txt, align=align
+            ),
+            on_change=self._on_change,
+        )
+        self.delete = 0
+
+    def _on_change(self, list_widget, jid_widget=None, text=None):
+        if jid_widget is not None:
+            if jid_widget != list_widget.contents[-1] and not text:
+                # if a field is empty, we delete the line (except for the last line)
+                list_widget.contents.remove(jid_widget)
+            elif jid_widget == list_widget.contents[-1] and text:
+                # we always want an empty field as last value to be able to add jids
+                list_widget.contents.append(sat_widgets.AdvancedEdit())
+
+    def _xmlui_get_selected_values(self):
+        # XXX: there is not selection in this list, so we return all non empty values
+        return [jid_ for jid_ in self.get_all_values() if jid_]
+
+
+class PrimitivusAdvancedListContainer(
+    xmlui.AdvancedListContainer, sat_widgets.TableContainer, PrimitivusEvents
+):
+    def __init__(self, _xmlui_parent, columns, selectable="no"):
+        options = {"ADAPT": ()}
+        if selectable != "no":
+            options["HIGHLIGHT"] = ()
+        sat_widgets.TableContainer.__init__(
+            self, columns=columns, options=options, row_selectable=selectable != "no"
+        )
+
+    def _xmlui_append(self, widget):
+        self.add_widget(widget)
+
+    def _xmlui_add_row(self, idx):
+        self.set_row_index(idx)
+
+    def _xmlui_get_selected_widgets(self):
+        return self.get_selected_widgets()
+
+    def _xmlui_get_selected_index(self):
+        return self.get_selected_index()
+
+    def _xmlui_on_select(self, callback):
+        """ Call callback with widget as only argument """
+        urwid.connect_signal(self, "click", self._event_callback, callback)
+
+
+class PrimitivusPairsContainer(xmlui.PairsContainer, sat_widgets.TableContainer):
+    def __init__(self, _xmlui_parent):
+        options = {"ADAPT": (0,), "HIGHLIGHT": (0,)}
+        if self._xmlui_main.type == "param":
+            options["FOCUS_ATTR"] = "param_selected"
+        sat_widgets.TableContainer.__init__(self, columns=2, options=options)
+
+    def _xmlui_append(self, widget):
+        if isinstance(widget, PrimitivusEmptyWidget):
+            # we don't want highlight on empty widgets
+            widget = urwid.AttrMap(widget, "default")
+        self.add_widget(widget)
+
+
+class PrimitivusLabelContainer(PrimitivusPairsContainer, xmlui.LabelContainer):
+    pass
+
+
+class PrimitivusTabsContainer(xmlui.TabsContainer, sat_widgets.TabsContainer):
+    def __init__(self, _xmlui_parent):
+        sat_widgets.TabsContainer.__init__(self)
+
+    def _xmlui_append(self, widget):
+        self.body.append(widget)
+
+    def _xmlui_add_tab(self, label, selected):
+        tab = PrimitivusVerticalContainer(None)
+        self.add_tab(label, tab, selected)
+        return tab
+
+
+class PrimitivusVerticalContainer(xmlui.VerticalContainer, urwid.ListBox):
+    BOX_HEIGHT = 5
+
+    def __init__(self, _xmlui_parent):
+        urwid.ListBox.__init__(self, urwid.SimpleListWalker([]))
+        self._last_size = None
+
+    def _xmlui_append(self, widget):
+        if "flow" not in widget.sizing():
+            widget = urwid.BoxAdapter(widget, self.BOX_HEIGHT)
+        self.body.append(widget)
+
+    def render(self, size, focus=False):
+        if size != self._last_size:
+            (maxcol, maxrow) = size
+            if self.body:
+                widget = self.body[0]
+                if isinstance(widget, urwid.BoxAdapter):
+                    widget.height = maxrow
+            self._last_size = size
+        return super(PrimitivusVerticalContainer, self).render(size, focus)
+
+
+### Dialogs ###
+
+
+class PrimitivusDialog(object):
+    def __init__(self, _xmlui_parent):
+        self.host = _xmlui_parent.host
+
+    def _xmlui_show(self):
+        self.host.show_pop_up(self)
+
+    def _xmlui_close(self):
+        self.host.remove_pop_up(self)
+
+
+class PrimitivusMessageDialog(PrimitivusDialog, xmlui.MessageDialog, sat_widgets.Alert):
+    def __init__(self, _xmlui_parent, title, message, level):
+        PrimitivusDialog.__init__(self, _xmlui_parent)
+        xmlui.MessageDialog.__init__(self, _xmlui_parent)
+        sat_widgets.Alert.__init__(
+            self, title, message, ok_cb=lambda __: self._xmlui_close()
+        )
+
+
+class PrimitivusNoteDialog(xmlui.NoteDialog, PrimitivusMessageDialog):
+    # TODO: separate NoteDialog
+    pass
+
+
+class PrimitivusConfirmDialog(
+    PrimitivusDialog, xmlui.ConfirmDialog, sat_widgets.ConfirmDialog
+):
+    def __init__(self, _xmlui_parent, title, message, level, buttons_set):
+        PrimitivusDialog.__init__(self, _xmlui_parent)
+        xmlui.ConfirmDialog.__init__(self, _xmlui_parent)
+        sat_widgets.ConfirmDialog.__init__(
+            self,
+            title,
+            message,
+            no_cb=lambda __: self._xmlui_cancelled(),
+            yes_cb=lambda __: self._xmlui_validated(),
+        )
+
+
+class PrimitivusFileDialog(
+    PrimitivusDialog, xmlui.FileDialog, files_management.FileDialog
+):
+    def __init__(self, _xmlui_parent, title, message, level, filetype):
+        # TODO: message is not managed yet
+        PrimitivusDialog.__init__(self, _xmlui_parent)
+        xmlui.FileDialog.__init__(self, _xmlui_parent)
+        style = []
+        if filetype == C.XMLUI_DATA_FILETYPE_DIR:
+            style.append("dir")
+        files_management.FileDialog.__init__(
+            self,
+            ok_cb=lambda path: self._xmlui_validated({"path": path}),
+            cancel_cb=lambda __: self._xmlui_cancelled(),
+            message=message,
+            title=title,
+            style=style,
+        )
+
+
+class GenericFactory(object):
+    def __getattr__(self, attr):
+        if attr.startswith("create"):
+            cls = globals()[
+                "Primitivus" + attr[6:]
+            ]  # XXX: we prefix with "Primitivus" to work around an Urwid bug, WidgetMeta in Urwid don't manage multiple inheritance with same names
+            return cls
+
+
+class WidgetFactory(GenericFactory):
+    def __getattr__(self, attr):
+        if attr.startswith("create"):
+            cls = GenericFactory.__getattr__(self, attr)
+            cls._xmlui_main = self._xmlui_main
+            return cls
+
+
+class XMLUIPanel(xmlui.XMLUIPanel, PrimitivusWidget):
+    widget_factory = WidgetFactory()
+
+    def __init__(
+        self,
+        host,
+        parsed_xml,
+        title=None,
+        flags=None,
+        callback=None,
+        ignore=None,
+        whitelist=None,
+        profile=C.PROF_KEY_NONE,
+    ):
+        self.widget_factory._xmlui_main = self
+        self._dest = None
+        xmlui.XMLUIPanel.__init__(
+            self,
+            host,
+            parsed_xml,
+            title=title,
+            flags=flags,
+            callback=callback,
+            ignore=ignore,
+            profile=profile,
+        )
+        PrimitivusWidget.__init__(self, self.main_cont, self.xmlui_title)
+
+
+    def _parse_childs(self, _xmlui_parent, current_node, wanted=("container",), data=None):
+        # Small hack to always have a VerticalContainer as main container in Primitivus.
+        # this used to be the default behaviour for all frontends, but now
+        # TabsContainer can also be the main container.
+        if _xmlui_parent is self:
+            node = current_node.childNodes[0]
+            if node.nodeName == "container" and node.getAttribute("type") == "tabs":
+                _xmlui_parent = self.widget_factory.createVerticalContainer(self)
+                self.main_cont = _xmlui_parent
+        return super(XMLUIPanel, self)._parse_childs(_xmlui_parent, current_node, wanted,
+                                                    data)
+
+
+    def construct_ui(self, parsed_dom):
+        def post_treat():
+            assert self.main_cont.body
+
+            if self.type in ("form", "popup"):
+                buttons = []
+                if self.type == "form":
+                    buttons.append(urwid.Button(_("Submit"), self.on_form_submitted))
+                    if not "NO_CANCEL" in self.flags:
+                        buttons.append(urwid.Button(_("Cancel"), self.on_form_cancelled))
+                else:
+                    buttons.append(
+                        urwid.Button(_("OK"), on_press=lambda __: self._xmlui_close())
+                    )
+                max_len = max([len(button.get_label()) for button in buttons])
+                grid_wid = urwid.GridFlow(buttons, max_len + 4, 1, 0, "center")
+                self.main_cont.body.append(grid_wid)
+            elif self.type == "param":
+                tabs_cont = self.main_cont.body[0].base_widget
+                assert isinstance(tabs_cont, sat_widgets.TabsContainer)
+                buttons = []
+                buttons.append(sat_widgets.CustomButton(_("Save"), self.on_save_params))
+                buttons.append(
+                    sat_widgets.CustomButton(
+                        _("Cancel"), lambda x: self.host.remove_window()
+                    )
+                )
+                max_len = max([button.get_size() for button in buttons])
+                grid_wid = urwid.GridFlow(buttons, max_len, 1, 0, "center")
+                tabs_cont.add_footer(grid_wid)
+
+        xmlui.XMLUIPanel.construct_ui(self, parsed_dom, post_treat)
+        urwid.WidgetWrap.__init__(self, self.main_cont)
+
+    def show(self, show_type=None, valign="middle"):
+        """Show the constructed UI
+        @param show_type: how to show the UI:
+            - None (follow XMLUI's recommendation)
+            - 'popup'
+            - 'window'
+        @param valign: vertical alignment when show_type is 'popup'.
+            Ignored when show_type is 'window'.
+
+        """
+        if show_type is None:
+            if self.type in ("window", "param"):
+                show_type = "window"
+            elif self.type in ("popup", "form"):
+                show_type = "popup"
+
+        if show_type not in ("popup", "window"):
+            raise ValueError("Invalid show_type [%s]" % show_type)
+
+        self._dest = show_type
+        if show_type == "popup":
+            self.host.show_pop_up(self, valign=valign)
+        elif show_type == "window":
+            self.host.new_widget(self, user_action=self.user_action)
+        else:
+            assert False
+        self.host.redraw()
+
+    def _xmlui_close(self):
+        if self._dest == "window":
+            self.host.remove_window()
+        elif self._dest == "popup":
+            self.host.remove_pop_up(self)
+        else:
+            raise exceptions.InternalError(
+                "self._dest unknown, are you sure you have called XMLUI.show ?"
+            )
+
+
+class XMLUIDialog(xmlui.XMLUIDialog):
+    dialog_factory = GenericFactory()
+
+
+xmlui.register_class(xmlui.CLASS_PANEL, XMLUIPanel)
+xmlui.register_class(xmlui.CLASS_DIALOG, XMLUIDialog)
+create = xmlui.create
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/constants.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core import constants
+from libervia.backend.core.i18n import _
+from collections import OrderedDict  # only available from python 2.7
+
+
+class Const(constants.Const):
+
+    PRESENCE = OrderedDict(
+        [
+            ("", _("Online")),
+            ("chat", _("Free for chat")),
+            ("away", _("Away from keyboard")),
+            ("dnd", _("Do not disturb")),
+            ("xa", _("Extended away")),
+        ]
+    )
+
+    # from plugin_misc_text_syntaxes
+    SYNTAX_XHTML = "XHTML"
+    SYNTAX_CURRENT = "@CURRENT@"
+    SYNTAX_TEXT = "text"
+
+    # XMLUI
+    SAT_FORM_PREFIX = "SAT_FORM_"
+    SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_"  # used to have unique elements names
+    XMLUI_STATUS_VALIDATED = "validated"
+    XMLUI_STATUS_CANCELLED = constants.Const.XMLUI_DATA_CANCELLED
+
+    # Roster
+    CONTACT_GROUPS = "groups"
+    CONTACT_RESOURCES = "resources"
+    CONTACT_MAIN_RESOURCE = "main_resource"
+    CONTACT_SPECIAL = "special"
+    CONTACT_SPECIAL_GROUP = "group"  # group chat special entity
+    CONTACT_SELECTED = "selected"
+    # used in handler to track where the contact is coming from
+    CONTACT_PROFILE = "profile"
+    CONTACT_SPECIAL_ALLOWED = (CONTACT_SPECIAL_GROUP,)  # allowed values for special flag
+    # set of forbidden names for contact data
+    CONTACT_DATA_FORBIDDEN = {
+        CONTACT_GROUPS,
+        CONTACT_RESOURCES,
+        CONTACT_MAIN_RESOURCE,
+        CONTACT_SELECTED,
+        CONTACT_PROFILE,
+    }
+
+    # Chats
+    CHAT_STATE_ICON = {
+        "": " ",
+        "active": "✔",
+        "inactive": "☄",
+        "gone": "✈",
+        "composing": "✎",
+        "paused": "…",
+    }
+
+    # Blogs
+    ENTRY_MODE_TEXT = "text"
+    ENTRY_MODE_RICH = "rich"
+    ENTRY_MODE_XHTML = "xhtml"
+
+    # Widgets management
+    # FIXME: should be in quick_frontend.constant, but Libervia doesn't inherit from it
+    WIDGET_NEW = "NEW"
+    WIDGET_KEEP = "KEEP"
+    WIDGET_RAISE = "RAISE"
+    WIDGET_RECREATE = "RECREATE"
+
+    # Updates (generic)
+    UPDATE_DELETE = "DELETE"
+    UPDATE_MODIFY = "MODIFY"
+    UPDATE_ADD = "ADD"
+    UPDATE_SELECTION = "SELECTION"
+    # high level update (i.e. not item level but organisation of items)
+    UPDATE_STRUCTURE = "STRUCTURE"
+
+    LISTENERS = {
+        "avatar",
+        "nicknames",
+        "presence",
+        "selected",
+        "notification",
+        "notificationsClear",
+        "widgetNew",
+        "widgetDeleted",
+        "profile_plugged",
+        "contactsFilled",
+        "disconnect",
+        "gotMenus",
+        "menu",
+        "progress_finished",
+        "progress_error",
+    }
+
+    # Notifications
+    NOTIFY_MESSAGE = "MESSAGE"  # a message has been received
+    NOTIFY_MENTION = "MENTION"  # user has been mentionned
+    NOTIFY_PROGRESS_END = "PROGRESS_END"  # a progression has finised
+    NOTIFY_GENERIC = "GENERIC"  # a notification which has not its own type
+    NOTIFY_ALL = (NOTIFY_MESSAGE, NOTIFY_MENTION, NOTIFY_PROGRESS_END, NOTIFY_GENERIC)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_app.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1387 @@
+#!/usr/bin/env python3
+
+# helper class for making a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.tools import trigger
+from libervia.backend.tools.common import data_format
+
+from libervia.frontends.tools import jid
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.frontends.quick_frontend import quick_menus
+from libervia.frontends.quick_frontend import quick_blog
+from libervia.frontends.quick_frontend import quick_chat, quick_games
+from libervia.frontends.quick_frontend import quick_contact_list
+from libervia.frontends.quick_frontend.constants import Const as C
+
+import sys
+import time
+
+
+log = getLogger(__name__)
+
+
+class ProfileManager(object):
+    """Class managing all data relative to one profile, and plugging in mechanism"""
+
+    # TODO: handle waiting XMLUI requests: getWaitingConf doesn't exist anymore
+    #       and a way to keep some XMLUI request between sessions is expected in backend
+    host = None
+    bridge = None
+    cache_keys_to_get = ['avatar', 'nicknames']
+
+    def __init__(self, profile):
+        self.profile = profile
+        self.connected = False
+        self.whoami = None
+        self.notifications = {}  # key: bare jid or '' for general, value: notif data
+
+    @property
+    def autodisconnect(self):
+        try:
+            autodisconnect = self._autodisconnect
+        except AttributeError:
+            autodisconnect = False
+        return autodisconnect
+
+    def plug(self):
+        """Plug the profile to the host"""
+        # first of all we create the contact lists
+        self.host.contact_lists.add_profile(self.profile)
+
+        # we get the essential params
+        self.bridge.param_get_a_async(
+            "JabberID",
+            "Connection",
+            profile_key=self.profile,
+            callback=self._plug_profile_jid,
+            errback=self._get_param_error,
+        )
+
+    def _plug_profile_jid(self, jid_s):
+        self.whoami = jid.JID(jid_s)  # resource might change after the connection
+        log.info(f"Our current jid is: {self.whoami}")
+        self.bridge.is_connected(self.profile, callback=self._plug_profile_isconnected)
+
+    def _autodisconnect_eb(self, failure_):
+        # XXX: we ignore error on this parameter, as Libervia can't access it
+        log.warning(
+            _("Error while trying to get autodisconnect param, ignoring: {}").format(
+                failure_
+            )
+        )
+        self._plug_profile_autodisconnect("false")
+
+    def _plug_profile_isconnected(self, connected):
+        self.connected = connected
+        if connected:
+            self.host.profile_connected(self.profile)
+        self.bridge.param_get_a_async(
+            "autodisconnect",
+            "Connection",
+            profile_key=self.profile,
+            callback=self._plug_profile_autodisconnect,
+            errback=self._autodisconnect_eb,
+        )
+
+    def _plug_profile_autodisconnect(self, autodisconnect):
+        if C.bool(autodisconnect):
+            self._autodisconnect = True
+        self.bridge.param_get_a_async(
+            "autoconnect",
+            "Connection",
+            profile_key=self.profile,
+            callback=self._plug_profile_autoconnect,
+            errback=self._get_param_error,
+        )
+
+    def _plug_profile_autoconnect(self, value_str):
+        autoconnect = C.bool(value_str)
+        if autoconnect and not self.connected:
+            self.host.connect(
+                self.profile, callback=lambda __: self._plug_profile_afterconnect()
+            )
+        else:
+            self._plug_profile_afterconnect()
+
+    def _plug_profile_afterconnect(self):
+        # Profile can be connected or not
+        # we get cached data
+        self.connected = True
+        self.host.bridge.features_get(
+            profile_key=self.profile,
+            callback=self._plug_profile_get_features_cb,
+            errback=self._plug_profile_get_features_eb,
+        )
+
+    def _plug_profile_get_features_eb(self, failure):
+        log.error("Couldn't get features: {}".format(failure))
+        self._plug_profile_get_features_cb({})
+
+    def _plug_profile_get_features_cb(self, features):
+        self.host.features = features
+        self.host.bridge.entities_data_get([], ProfileManager.cache_keys_to_get,
+                                         profile=self.profile,
+                                         callback=self._plug_profile_got_cached_values,
+                                         errback=self._plug_profile_failed_cached_values)
+
+    def _plug_profile_failed_cached_values(self, failure):
+        log.error("Couldn't get cached values: {}".format(failure))
+        self._plug_profile_got_cached_values({})
+
+    def _plug_profile_got_cached_values(self, cached_values):
+        contact_list = self.host.contact_lists[self.profile]
+        # add the contact list and its listener
+        for entity_s, data in cached_values.items():
+            for key, value in data.items():
+                self.host.entity_data_updated_handler(entity_s, key, value, self.profile)
+
+        if not self.connected:
+            self.host.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=self.profile)
+        else:
+
+            contact_list.fill()
+            self.host.set_presence_status(profile=self.profile)
+
+            # The waiting subscription requests
+            self.bridge.sub_waiting_get(
+                self.profile, callback=self._plug_profile_got_waiting_sub
+            )
+
+    def _plug_profile_got_waiting_sub(self, waiting_sub):
+        for sub in waiting_sub:
+            self.host.subscribe_handler(waiting_sub[sub], sub, self.profile)
+
+        self.bridge.muc_get_rooms_joined(
+            self.profile, callback=self._plug_profile_got_rooms_joined
+        )
+
+    def _plug_profile_got_rooms_joined(self, rooms_args):
+        # Now we open the MUC window where we already are:
+        for room_args in rooms_args:
+            self.host.muc_room_joined_handler(*room_args, profile=self.profile)
+        # Presence must be requested after rooms are filled
+        self.host.bridge.presence_statuses_get(
+            self.profile, callback=self._plug_profile_got_presences
+        )
+
+    def _plug_profile_got_presences(self, presences):
+        for contact in presences:
+            for res in presences[contact]:
+                jabber_id = ("%s/%s" % (jid.JID(contact).bare, res)) if res else contact
+                show = presences[contact][res][0]
+                priority = presences[contact][res][1]
+                statuses = presences[contact][res][2]
+                self.host.presence_update_handler(
+                    jabber_id, show, priority, statuses, self.profile
+                )
+
+        # At this point, profile should be fully plugged
+        # and we launch frontend specific method
+        self.host.profile_plugged(self.profile)
+
+    def _get_param_error(self, failure):
+        log.error(_("Can't get profile parameter: {msg}").format(msg=failure))
+
+
+class ProfilesManager(object):
+    """Class managing collection of profiles"""
+
+    def __init__(self):
+        self._profiles = {}
+
+    def __contains__(self, profile):
+        return profile in self._profiles
+
+    def __iter__(self):
+        return iter(self._profiles.keys())
+
+    def __getitem__(self, profile):
+        return self._profiles[profile]
+
+    def __len__(self):
+        return len(self._profiles)
+
+    def items(self):
+        return self._profiles.items()
+
+    def values(self):
+        return self._profiles.values()
+
+    def plug(self, profile):
+        if profile in self._profiles:
+            raise exceptions.ConflictError(
+                "A profile of the name [{}] is already plugged".format(profile)
+            )
+        self._profiles[profile] = ProfileManager(profile)
+        self._profiles[profile].plug()
+
+    def unplug(self, profile):
+        if profile not in self._profiles:
+            raise ValueError("The profile [{}] is not plugged".format(profile))
+
+        # remove the contact list and its listener
+        host = self._profiles[profile].host
+        host.contact_lists[profile].unplug()
+
+        del self._profiles[profile]
+
+    def choose_one_profile(self):
+        return list(self._profiles.keys())[0]
+
+
+class QuickApp(object):
+    """This class contain the main methods needed for the frontend"""
+
+    MB_HANDLER = True  #: Set to False if the frontend doesn't manage microblog
+    AVATARS_HANDLER = True  #: set to False if avatars are not used
+    ENCRYPTION_HANDLERS = True  #: set to False if encryption is handled separatly
+    #: if True, QuickApp will call resync itself, on all widgets at the same time
+    #: if False, frontend must call resync itself when suitable (e.g. widget is being
+    #: visible)
+    AUTO_RESYNC = True
+
+    def __init__(self, bridge_factory, xmlui, check_options=None, connect_bridge=True):
+        """Create a frontend application
+
+        @param bridge_factory: method to use to create the bridge
+        @param xmlui: xmlui module
+        @param check_options: method to call to check options (usually command line
+            arguments)
+        """
+        self.xmlui = xmlui
+        self.menus = quick_menus.QuickMenusManager(self)
+        ProfileManager.host = self
+        self.profiles = ProfilesManager()
+        # profiles currently being plugged, used to (un)lock contact list updates
+        self._plugs_in_progress = set()
+        self.ready_profiles = set()  # profiles which are connected and ready
+        self.signals_cache = {}  # used to keep signal received between start of
+                                 # plug_profile and when the profile is actualy ready
+        self.contact_lists = quick_contact_list.QuickContactListHandler(self)
+        self.widgets = quick_widgets.QuickWidgetsManager(self)
+        if check_options is not None:
+            self.options = check_options()
+        else:
+            self.options = None
+
+        # see selected_widget setter and getter
+        self._selected_widget = None
+
+        # listeners are callable watching events
+        self._listeners = {}  # key: listener type ("avatar", "selected", etc),
+                              # value: list of callbacks
+
+        # triggers
+        self.trigger = (
+            trigger.TriggerManager()
+        )  # trigger are used to change the default behaviour
+
+        ## bridge ##
+        self.bridge = bridge_factory()
+        ProfileManager.bridge = self.bridge
+        if connect_bridge:
+            self.connect_bridge()
+
+        # frontend notifications
+        self._notif_id = 0
+        self._notifications = {}
+        # watched progresses and associated callbacks
+        self._progress_ids = {}
+        # available features
+        # FIXME: features are profile specific, to be checked
+        self.features = None
+        #: map of short name to namespaces
+        self.ns_map = {}
+        #: available encryptions
+        self.encryption_plugins = []
+        # state of synchronisation with backend
+        self._sync = True
+
+    def connect_bridge(self):
+        self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb)
+
+    def _namespaces_get_cb(self, ns_map):
+        self.ns_map = ns_map
+
+    def _namespaces_get_eb(self, failure_):
+        log.error(_("Can't get namespaces map: {msg}").format(msg=failure_))
+
+    def _encryption_plugins_get_cb(self, plugins_ser):
+        self.encryption_plugins = data_format.deserialise(plugins_ser, type_check=list)
+
+    def _encryption_plugins_get_eb(self, failure_):
+        log.warning(_("Can't retrieve encryption plugins: {msg}").format(msg=failure_))
+
+    def on_bridge_connected(self):
+        self.bridge.ready_get(self.on_backend_ready)
+
+    def _bridge_cb(self):
+        self.register_signal("connected")
+        self.register_signal("disconnected")
+        self.register_signal("action_new")
+        self.register_signal("contact_new")
+        self.register_signal("message_new")
+        if self.ENCRYPTION_HANDLERS:
+            self.register_signal("message_encryption_started")
+            self.register_signal("message_encryption_stopped")
+        self.register_signal("presence_update")
+        self.register_signal("subscribe")
+        self.register_signal("param_update")
+        self.register_signal("contact_deleted")
+        self.register_signal("entity_data_updated")
+        self.register_signal("progress_started")
+        self.register_signal("progress_finished")
+        self.register_signal("progress_error")
+        self.register_signal("muc_room_joined", iface="plugin")
+        self.register_signal("muc_room_left", iface="plugin")
+        self.register_signal("muc_room_user_changed_nick", iface="plugin")
+        self.register_signal("muc_room_new_subject", iface="plugin")
+        self.register_signal("chat_state_received", iface="plugin")
+        self.register_signal("message_state", iface="plugin")
+        self.register_signal("ps_event", iface="plugin")
+        # useful for debugging
+        self.register_signal("_debug", iface="core")
+
+        # FIXME: do it dynamically
+        quick_games.Tarot.register_signals(self)
+        quick_games.Quiz.register_signals(self)
+        quick_games.Radiocol.register_signals(self)
+        self.on_bridge_connected()
+
+    def _bridge_eb(self, failure):
+        if isinstance(failure, exceptions.BridgeExceptionNoService):
+            print((_("Can't connect to SàT backend, are you sure it's launched ?")))
+            sys.exit(C.EXIT_BACKEND_NOT_FOUND)
+        elif isinstance(failure, exceptions.BridgeInitError):
+            print((_("Can't init bridge")))
+            sys.exit(C.EXIT_BRIDGE_ERROR)
+        else:
+            print((_("Error while initialising bridge: {}".format(failure))))
+
+    def on_backend_ready(self):
+        log.info("backend is ready")
+        self.bridge.namespaces_get(
+            callback=self._namespaces_get_cb, errback=self._namespaces_get_eb)
+        # we cache available encryption plugins, as we'll use them on each
+        # new chat widget
+        self.bridge.encryption_plugins_get(
+            callback=self._encryption_plugins_get_cb,
+            errback=self._encryption_plugins_get_eb)
+
+
+    @property
+    def current_profile(self):
+        """Profile that a user would expect to use"""
+        try:
+            return self.selected_widget.profile
+        except (TypeError, AttributeError):
+            return self.profiles.choose_one_profile()
+
+    @property
+    def visible_widgets(self):
+        """Widgets currently visible
+
+        This must be implemented by frontend
+        @return (iter[object]): iterable on visible widgets
+            widgets can be QuickWidgets or not
+        """
+        raise NotImplementedError
+
+    @property
+    def visible_quick_widgets(self):
+        """QuickWidgets currently visible
+
+        This generator iterate only on QuickWidgets, discarding other kinds of
+        widget the frontend may have.
+        @return (iter[object]): iterable on visible widgets
+        """
+        for w in self.visisble_widgets:
+            if isinstance(w, quick_widgets.QuickWidget):
+                return w
+
+    @property
+    def selected_widget(self):
+        """widget currently selected
+
+        This must be set by frontend using setter.
+        """
+        return self._selected_widget
+
+    @selected_widget.setter
+    def selected_widget(self, wid):
+        """Set the currently selected widget
+
+        Must be set by frontend
+        """
+        if self._selected_widget == wid:
+            return
+        self._selected_widget = wid
+        try:
+            on_selected = wid.on_selected
+        except AttributeError:
+            pass
+        else:
+            on_selected()
+
+        self.call_listeners("selected", wid)
+
+    # backend state management
+
+    @property
+    def sync(self):
+        """Synchronization flag
+
+        True if this frontend is synchronised with backend
+        """
+        return self._sync
+
+    @sync.setter
+    def sync(self, state):
+        """Called when backend is desynchronised or resynchronising
+
+        @param state(bool): True: if the backend is resynchronising
+            False when we lose synchronisation, for instance if frontend is going to sleep
+            or if connection has been lost and a reconnection is needed
+        """
+        if state:
+            log.debug("we are synchronised with server")
+            if self.AUTO_RESYNC:
+                # we are resynchronising all widgets
+                log.debug("doing a full widgets resynchronisation")
+                for w in self.widgets:
+                    try:
+                        resync = w.resync
+                    except AttributeError:
+                        pass
+                    else:
+                        resync()
+                self.contact_lists.fill()
+
+            self._sync = state
+        else:
+            log.debug("we have lost synchronisation with server")
+            self._sync = state
+            # we've lost synchronisation, all widgets must be notified
+            # note: this is always called independently of AUTO_RESYNC
+            for w in self.widgets:
+                try:
+                    w.sync = False
+                except AttributeError:
+                    pass
+
+    def register_signal(
+        self, function_name, handler=None, iface="core", with_profile=True
+    ):
+        """Register a handler for a signal
+
+        @param function_name (str): name of the signal to handle
+        @param handler (instancemethod): method to call when the signal arrive,
+            None for calling an automatically named handler (function_name + 'Handler')
+        @param iface (str): interface of the bridge to use ('core' or 'plugin')
+        @param with_profile (boolean): True if the signal concerns a specific profile,
+            in that case the profile name has to be passed by the caller
+        """
+        log.debug("registering signal {name}".format(name=function_name))
+        if handler is None:
+            handler = getattr(self, "{}{}".format(function_name, "_handler"))
+        if not with_profile:
+            self.bridge.register_signal(function_name, handler, iface)
+            return
+
+        def signal_received(*args, **kwargs):
+            profile = kwargs.get("profile")
+            if profile is None:
+                if not args:
+                    raise exceptions.ProfileNotSetError
+                profile = args[-1]
+            if profile is not None:
+                if not self.check_profile(profile):
+                    if profile in self.profiles:
+                        # profile is not ready but is in self.profiles, that's mean that
+                        # it's being connecting and we need to cache the signal
+                        self.signals_cache.setdefault(profile, []).append(
+                            (function_name, handler, args, kwargs)
+                        )
+                    return  # we ignore signal for profiles we don't manage
+            handler(*args, **kwargs)
+
+        self.bridge.register_signal(function_name, signal_received, iface)
+
+    def addListener(self, type_, callback, profiles_filter=None):
+        """Add a listener for an event
+
+        /!\ don't forget to remove listener when not used anymore (e.g. if you delete a
+            widget)
+        @param type_: type of event, can be:
+            - contactsFilled: called when contact have been fully filled for a profiles
+                kwargs: profile
+            - avatar: called when avatar data is updated
+                args: (entity, avatar_data, profile)
+            - nicknames: called when nicknames data is updated
+                args: (entity, nicknames, profile)
+            - presence: called when a presence is received
+                args: (entity, show, priority, statuses, profile)
+            - selected: called when a widget is selected
+                args: (selected_widget,)
+            - notification: called when a new notification is emited
+                args: (entity, notification_data, profile)
+            - notificationsClear: called when notifications are cleared
+                args: (entity, type_, profile)
+            - widgetNew: a new QuickWidget has been created
+                args: (widget,)
+            - widgetDeleted: all instances of a widget with specific hash have been
+                deleted
+                args: (widget_deleted,)
+            - menu: called when a menu item is added or removed
+                args: (type_, path, path_i18n, item) were values are:
+                    type_: same as in [sat.core.sat_main.SAT.import_menu]
+                    path: same as in [sat.core.sat_main.SAT.import_menu]
+                    path_i18n: translated path (or None if the item is removed)
+                    item: instance of quick_menus.MenuItemBase or None if the item is
+                          removed
+            - gotMenus: called only once when menu are available (no arg)
+            - progress_finished: called when a progressing action has just finished
+                args:  (progress_id, metadata, profile)
+            - progress_error: called when a progressing action failed
+                args: (progress_id, error_msg, profile):
+        @param callback: method to call on event
+        @param profiles_filter (set[unicode]): if set and not empty, the
+            listener will be callable only by one of the given profiles.
+        """
+        assert type_ in C.LISTENERS
+        self._listeners.setdefault(type_, {})[callback] = profiles_filter
+
+    def removeListener(self, type_, callback, ignore_missing=False):
+        """Remove a callback from listeners
+
+        @param type_(str): same as for [addListener]
+        @param callback(callable): callback to remove
+        @param ignore_missing(bool): if True, don't log error if the listener doesn't
+            exist
+        """
+        assert type_ in C.LISTENERS
+        try:
+            self._listeners[type_].pop(callback)
+        except KeyError:
+            if not ignore_missing:
+                log.error(
+                    f"Trying to remove an inexisting listener (type = {type_}): "
+                    f"{callback}")
+
+    def call_listeners(self, type_, *args, **kwargs):
+        """Call the methods which listen type_ event. If a profiles filter has
+        been register with a listener and profile argument is not None, the
+        listener will be called only if profile is in the profiles filter list.
+
+        @param type_: same as for [addListener]
+        @param *args: arguments sent to callback
+        @param **kwargs: keywords argument, mainly used to pass "profile" when needed
+        """
+        assert type_ in C.LISTENERS
+        try:
+            listeners = self._listeners[type_]
+        except KeyError:
+            pass
+        else:
+            profile = kwargs.get("profile")
+            for listener, profiles_filter in list(listeners.items()):
+                if profile is None or not profiles_filter or profile in profiles_filter:
+                    listener(*args, **kwargs)
+
+    def check_profile(self, profile):
+        """Tell if the profile is currently followed by the application, and ready"""
+        return profile in self.ready_profiles
+
+    def post_init(self, profile_manager):
+        """Must be called after initialization is done, do all automatic task
+
+        (auto plug profile)
+        @param profile_manager: instance of a subclass of
+            Quick_frontend.QuickProfileManager
+        """
+        if self.options and self.options.profile:
+            profile_manager.autoconnect([self.options.profile])
+
+    def profile_plugged(self, profile):
+        """Method called when the profile is fully plugged
+
+        This will launch frontend specific workflow
+
+        /!\ if you override the method and don't call the parent, be sure to add the
+            profile to ready_profiles ! if you don't, all signals will stay in cache
+
+        @param profile(unicode): %(doc_profile)s
+        """
+        self._plugs_in_progress.remove(profile)
+        self.ready_profiles.add(profile)
+
+        # profile is ready, we can call send signals that where is cache
+        cached_signals = self.signals_cache.pop(profile, [])
+        for function_name, handler, args, kwargs in cached_signals:
+            log.debug(
+                "Calling cached signal [%s] with args %s and kwargs %s"
+                % (function_name, args, kwargs)
+            )
+            handler(*args, **kwargs)
+
+        self.call_listeners("profile_plugged", profile=profile)
+        if not self._plugs_in_progress:
+            self.contact_lists.lock_update(False)
+
+    def profile_connected(self, profile):
+        """Called when a plugged profile is connected
+
+        it is called independently of profile_plugged (may be called before or after
+        profile_plugged)
+        """
+        pass
+
+    def connect(self, profile, callback=None, errback=None):
+        if not callback:
+            callback = lambda __: None
+        if not errback:
+
+            def errback(failure):
+                log.error(_("Can't connect profile [%s]") % failure)
+                try:
+                    module = failure.module
+                except AttributeError:
+                    module = ""
+                try:
+                    message = failure.message
+                except AttributeError:
+                    message = "error"
+                try:
+                    fullname = failure.fullname
+                except AttributeError:
+                    fullname = "error"
+                if (
+                    module.startswith("twisted.words.protocols.jabber")
+                    and failure.condition == "not-authorized"
+                ):
+                    self.action_launch(C.CHANGE_XMPP_PASSWD_ID, {}, profile=profile)
+                else:
+                    self.show_dialog(message, fullname, "error")
+
+        self.bridge.connect(profile, callback=callback, errback=errback)
+
+    def plug_profiles(self, profiles):
+        """Tell application which profiles must be used
+
+        @param profiles: list of valid profile names
+        """
+        self.contact_lists.lock_update()
+        self._plugs_in_progress.update(profiles)
+        self.plugging_profiles()
+        for profile in profiles:
+            self.profiles.plug(profile)
+
+    def plugging_profiles(self):
+        """Method to subclass to manage frontend specific things to do
+
+        will be called when profiles are choosen and are to be plugged soon
+        """
+        pass
+
+    def unplug_profile(self, profile):
+        """Tell the application to not follow anymore the profile"""
+        if not profile in self.profiles:
+            raise ValueError("The profile [{}] is not plugged".format(profile))
+        self.profiles.unplug(profile)
+
+    def clear_profile(self):
+        self.profiles.clear()
+
+    def new_widget(self, widget):
+        raise NotImplementedError
+
+    # bridge signals hanlers
+
+    def connected_handler(self, jid_s, profile):
+        """Called when the connection is made.
+
+        @param jid_s (unicode): the JID that we were assigned by the server,
+            as the resource might differ from the JID we asked for.
+        """
+        log.debug(_("Connected"))
+        self.profiles[profile].whoami = jid.JID(jid_s)
+        self.set_presence_status(profile=profile)
+        # FIXME: fill() is already called for all profiles when doing self.sync = True
+        #        a per-profile fill() should be done once, see below note
+        self.contact_lists[profile].fill()
+        # if we were already displaying widgets, they must be resynchronized
+        # FIXME: self.sync is for all profiles
+        #        while (dis)connection is per-profile.
+        #        A mechanism similar to sync should be available
+        #        on a per-profile basis
+        self.sync = True
+        self.profile_connected(profile)
+
+    def disconnected_handler(self, profile):
+        """called when the connection is closed"""
+        log.debug(_("Disconnected"))
+        self.contact_lists[profile].disconnect()
+        # FIXME: see note on connected_handler
+        self.sync = False
+        self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)
+
+    def action_new_handler(self, action_data_s, id_, security_limit, profile):
+        self.action_manager(
+            data_format.deserialise(action_data_s), user_action=False, profile=profile
+        )
+
+    def contact_new_handler(self, jid_s, attributes, groups, profile):
+        entity = jid.JID(jid_s)
+        groups = list(groups)
+        self.contact_lists[profile].set_contact(entity, groups, attributes, in_roster=True)
+
+    def message_new_handler(
+            self, uid, timestamp, from_jid_s, to_jid_s, msg, subject, type_, extra_s,
+            profile):
+        from_jid = jid.JID(from_jid_s)
+        to_jid = jid.JID(to_jid_s)
+        extra = data_format.deserialise(extra_s)
+        if not self.trigger.point(
+            "messageNewTrigger", uid, timestamp, from_jid, to_jid, msg, subject, type_,
+            extra, profile=profile,):
+            return
+
+        from_me = from_jid.bare == self.profiles[profile].whoami.bare
+        mess_to_jid = to_jid if from_me else from_jid
+        target = mess_to_jid.bare
+        contact_list = self.contact_lists[profile]
+
+        try:
+            is_room = contact_list.is_room(target)
+        except exceptions.NotFound:
+            is_room = False
+
+        if target.resource and not is_room:
+            # we avoid resource locking, but we must keep resource for private MUC
+            # messages
+            target = target
+        # we want to be sure to have at least one QuickChat instance
+        self.widgets.get_or_create_widget(
+            quick_chat.QuickChat,
+            target,
+            type_ = C.CHAT_GROUP if is_room else C.CHAT_ONE2ONE,
+            on_new_widget = None,
+            profile = profile,
+        )
+
+        if (
+            not from_jid in contact_list
+            and from_jid.bare != self.profiles[profile].whoami.bare
+        ):
+            # XXX: needed to show entities which haven't sent any
+            #     presence information and which are not in roster
+            contact_list.set_contact(from_jid)
+
+        # we dispatch the message in the widgets
+        for widget in self.widgets.get_widgets(
+            quick_chat.QuickChat, target=target, profiles=(profile,)
+        ):
+            widget.message_new(
+                uid, timestamp, from_jid, mess_to_jid, msg, subject, type_, extra, profile
+            )
+
+    def message_encryption_started_handler(self, destinee_jid_s, plugin_data, profile):
+        destinee_jid = jid.JID(destinee_jid_s)
+        plugin_data = data_format.deserialise(plugin_data)
+        for widget in self.widgets.get_widgets(quick_chat.QuickChat,
+                                              target=destinee_jid.bare,
+                                              profiles=(profile,)):
+            widget.message_encryption_started(plugin_data)
+
+    def message_encryption_stopped_handler(self, destinee_jid_s, plugin_data, profile):
+        destinee_jid = jid.JID(destinee_jid_s)
+        for widget in self.widgets.get_widgets(quick_chat.QuickChat,
+                                              target=destinee_jid.bare,
+                                              profiles=(profile,)):
+            widget.message_encryption_stopped(plugin_data)
+
+    def message_state_handler(self, uid, status, profile):
+        for widget in self.widgets.get_widgets(quick_chat.QuickChat, profiles=(profile,)):
+            widget.on_message_state(uid, status, profile)
+
+    def message_send(self, to_jid, message, subject=None, mess_type="auto", extra=None, callback=None, errback=None, profile_key=C.PROF_KEY_NONE):
+        if not subject and not extra and (not message or message == {'': ''}):
+            log.debug("Not sending empty message")
+            return
+
+        if subject is None:
+            subject = {}
+        if extra is None:
+            extra = {}
+        if callback is None:
+            callback = (
+                lambda __=None: None
+            )  # FIXME: optional argument is here because pyjamas doesn't support callback
+               #        without arg with json proxy
+        if errback is None:
+            errback = lambda failure: self.show_dialog(
+                message=failure.message, title=failure.fullname, type="error"
+            )
+
+        if not self.trigger.point("messageSendTrigger", to_jid, message, subject, mess_type, extra, callback, errback, profile_key=profile_key):
+            return
+
+        self.bridge.message_send(
+            str(to_jid),
+            message,
+            subject,
+            mess_type,
+            data_format.serialise(extra),
+            profile_key,
+            callback=callback,
+            errback=errback,
+        )
+
+    def set_presence_status(self, show="", status=None, profile=C.PROF_KEY_NONE):
+        raise NotImplementedError
+
+    def presence_update_handler(self, entity_s, show, priority, statuses, profile):
+        # XXX: this log is commented because it's really too verbose even for DEBUG logs
+        #      but it is kept here as it may still be useful for troubleshooting
+        # log.debug(
+        #     _(
+        #         u"presence update for %(entity)s (show=%(show)s, priority=%(priority)s, "
+        #         u"statuses=%(statuses)s) [profile:%(profile)s]"
+        #     )
+        #     % {
+        #         "entity": entity_s,
+        #         C.PRESENCE_SHOW: show,
+        #         C.PRESENCE_PRIORITY: priority,
+        #         C.PRESENCE_STATUSES: statuses,
+        #         "profile": profile,
+        #     }
+        # )
+        entity = jid.JID(entity_s)
+
+        if entity == self.profiles[profile].whoami:
+            if show == C.PRESENCE_UNAVAILABLE:
+                self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)
+            else:
+                # FIXME: try to retrieve user language status before fallback to default
+                status = statuses.get(C.PRESENCE_STATUSES_DEFAULT, None)
+                self.set_presence_status(show, status, profile=profile)
+            return
+
+        self.call_listeners("presence", entity, show, priority, statuses, profile=profile)
+
+    def muc_room_joined_handler(
+            self, room_jid_s, occupants, user_nick, subject, statuses, profile):
+        """Called when a MUC room is joined"""
+        log.debug(
+            "Room [{room_jid}] joined by {profile}, users presents:{users}".format(
+                room_jid=room_jid_s, profile=profile, users=list(occupants.keys())
+            )
+        )
+        room_jid = jid.JID(room_jid_s)
+        self.contact_lists[profile].set_special(room_jid, C.CONTACT_SPECIAL_GROUP)
+        self.widgets.get_or_create_widget(
+            quick_chat.QuickChat,
+            room_jid,
+            type_=C.CHAT_GROUP,
+            nick=user_nick,
+            occupants=occupants,
+            subject=subject,
+            statuses=statuses,
+            profile=profile,
+        )
+
+    def muc_room_left_handler(self, room_jid_s, profile):
+        """Called when a MUC room is left"""
+        log.debug(
+            "Room [%(room_jid)s] left by %(profile)s"
+            % {"room_jid": room_jid_s, "profile": profile}
+        )
+        room_jid = jid.JID(room_jid_s)
+        chat_widget = self.widgets.get_widget(quick_chat.QuickChat, room_jid, profile)
+        if chat_widget:
+            self.widgets.delete_widget(
+                chat_widget, all_instances=True, explicit_close=True)
+        self.contact_lists[profile].remove_contact(room_jid)
+
+    def muc_room_user_changed_nick_handler(self, room_jid_s, old_nick, new_nick, profile):
+        """Called when an user joined a MUC room"""
+        room_jid = jid.JID(room_jid_s)
+        chat_widget = self.widgets.get_or_create_widget(
+            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
+        )
+        chat_widget.change_user_nick(old_nick, new_nick)
+        log.debug(
+            "user [%(old_nick)s] is now known as [%(new_nick)s] in room [%(room_jid)s]"
+            % {"old_nick": old_nick, "new_nick": new_nick, "room_jid": room_jid}
+        )
+
+    def muc_room_new_subject_handler(self, room_jid_s, subject, profile):
+        """Called when subject of MUC room change"""
+        room_jid = jid.JID(room_jid_s)
+        chat_widget = self.widgets.get_or_create_widget(
+            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
+        )
+        chat_widget.set_subject(subject)
+        log.debug(
+            "new subject for room [%(room_jid)s]: %(subject)s"
+            % {"room_jid": room_jid, "subject": subject}
+        )
+
+    def chat_state_received_handler(self, from_jid_s, state, profile):
+        """Called when a new chat state (XEP-0085) is received.
+
+        @param from_jid_s (unicode): JID of a contact or C.ENTITY_ALL
+        @param state (unicode): new state
+        @param profile (unicode): current profile
+        """
+        from_jid = jid.JID(from_jid_s)
+        for widget in self.widgets.get_widgets(quick_chat.QuickChat, target=from_jid.bare,
+                                              profiles=(profile,)):
+            widget.on_chat_state(from_jid, state, profile)
+
+    def notify(self, type_, entity=None, message=None, subject=None, callback=None,
+               cb_args=None, widget=None, profile=C.PROF_KEY_NONE):
+        """Trigger an event notification
+
+        @param type_(unicode): notifation kind,
+            one of C.NOTIFY_* constant or any custom type specific to frontend
+        @param entity(jid.JID, None): entity involved in the notification
+            if entity is in contact list, a indicator may be added in front of it
+        @param message(unicode, None): message of the notification
+        @param subject(unicode, None): subject of the notification
+        @param callback(callable, None): method to call when notification is selected
+        @param cb_args(list, None): list of args for callback
+        @param widget(object, None): widget where the notification happened
+        """
+        assert type_ in C.NOTIFY_ALL
+        notif_dict = self.profiles[profile].notifications
+        key = "" if entity is None else entity.bare
+        type_notifs = notif_dict.setdefault(key, {}).setdefault(type_, [])
+        notif_data = {
+            "id": self._notif_id,
+            "time": time.time(),
+            "entity": entity,
+            "callback": callback,
+            "cb_args": cb_args,
+            "message": message,
+            "subject": subject,
+        }
+        if widget is not None:
+            notif_data[widget] = widget
+        type_notifs.append(notif_data)
+        self._notifications[self._notif_id] = notif_data
+        self._notif_id += 1
+        self.call_listeners("notification", entity, notif_data, profile=profile)
+
+    def get_notifs(self, entity=None, type_=None, exact_jid=None, profile=C.PROF_KEY_NONE):
+        """return notifications for given entity
+
+        @param entity(jid.JID, None, C.ENTITY_ALL): jid of the entity to check
+            bare jid to get all notifications, full jid to filter on resource
+            None to get general notifications
+            C.ENTITY_ALL to get all notifications
+        @param type_(unicode, None): notification type to filter
+            None to get all notifications
+        @param exact_jid(bool, None): if True, only return notifications from
+            exact entity jid (i.e. not including other resources)
+            None for automatic selection (True for full jid, False else)
+            False to get resources notifications
+            False doesn't do anything if entity is not a bare jid
+        @return (iter[dict]): notifications
+        """
+        main_notif_dict = self.profiles[profile].notifications
+
+        if entity is C.ENTITY_ALL:
+            selected_notifs = iter(main_notif_dict.values())
+            exact_jid = False
+        else:
+            if entity is None:
+                key = ""
+                exact_jid = False
+            else:
+                key = entity.bare
+                if exact_jid is None:
+                    exact_jid = bool(entity.resource)
+            selected_notifs = (main_notif_dict.setdefault(key, {}),)
+
+        for notifs_from_select in selected_notifs:
+
+            if type_ is None:
+                type_notifs = iter(notifs_from_select.values())
+            else:
+                type_notifs = (notifs_from_select.get(type_, []),)
+
+            for notifs in type_notifs:
+                for notif in notifs:
+                    if exact_jid and notif["entity"] != entity:
+                        continue
+                    yield notif
+
+    def clear_notifs(self, entity, type_=None, profile=C.PROF_KEY_NONE):
+        """return notifications for given entity
+
+        @param entity(jid.JID, None): bare jid of the entity to check
+            None to clear general notifications (but keep entities ones)
+        @param type_(unicode, None): notification type to filter
+            None to clear all notifications
+        @return (list[dict]): list of notifications
+        """
+        notif_dict = self.profiles[profile].notifications
+        key = "" if entity is None else entity.bare
+        try:
+            if type_ is None:
+                del notif_dict[key]
+            else:
+                del notif_dict[key][type_]
+        except KeyError:
+            return
+        self.call_listeners("notificationsClear", entity, type_, profile=profile)
+
+    def ps_event_handler(self, category, service_s, node, event_type, data, profile):
+        """Called when a PubSub event is received.
+
+        @param category(unicode): event category (e.g. "PEP", "MICROBLOG")
+        @param service_s (unicode): pubsub service
+        @param node (unicode): pubsub node
+        @param event_type (unicode): event type (one of C.PUBLISH, C.RETRACT, C.DELETE)
+        @param data (serialised_dict): event data
+        """
+        data = data_format.deserialise(data)
+        service_s = jid.JID(service_s)
+
+        if category == C.PS_MICROBLOG and self.MB_HANDLER:
+            if event_type == C.PS_PUBLISH:
+                if not "content" in data:
+                    log.warning("No content found in microblog data")
+                    return
+
+                # FIXME: check if [] make sense (instead of None)
+                _groups = data.get("group")
+
+                for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
+                    wid.add_entry_if_accepted(service_s, node, data, _groups, profile)
+
+                try:
+                    comments_node, comments_service = (
+                        data["comments_node"],
+                        data["comments_service"],
+                    )
+                except KeyError:
+                    pass
+                else:
+                    self.bridge.mb_get(
+                        comments_service,
+                        comments_node,
+                        C.NO_LIMIT,
+                        [],
+                        {"subscribe": C.BOOL_TRUE},
+                        profile=profile,
+                    )
+            elif event_type == C.PS_RETRACT:
+                for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
+                    wid.delete_entry_if_present(service_s, node, data["id"], profile)
+                pass
+            else:
+                log.warning("Unmanaged PubSub event type {}".format(event_type))
+
+    def register_progress_cbs(self, progress_id, callback, errback):
+        """Register progression callbacks
+
+        @param progress_id(unicode): id of the progression to check
+        @param callback(callable, None): method to call when progressing action
+            successfuly finished.
+            None to ignore
+        @param errback(callable, None): method to call when progressions action failed
+            None to ignore
+        """
+        callbacks = self._progress_ids.setdefault(progress_id, [])
+        callbacks.append((callback, errback))
+
+    def progress_started_handler(self, pid, metadata, profile):
+        log.info("Progress {} started".format(pid))
+
+    def progress_finished_handler(self, pid, metadata, profile):
+        log.info("Progress {} finished".format(pid))
+        try:
+            callbacks = self._progress_ids.pop(pid)
+        except KeyError:
+            pass
+        else:
+            for callback, __ in callbacks:
+                if callback is not None:
+                    callback(metadata, profile=profile)
+        self.call_listeners("progress_finished", pid, metadata, profile=profile)
+
+    def progress_error_handler(self, pid, err_msg, profile):
+        log.warning("Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg))
+        try:
+            callbacks = self._progress_ids.pop(pid)
+        except KeyError:
+            pass
+        else:
+            for __, errback in callbacks:
+                if errback is not None:
+                    errback(err_msg, profile=profile)
+        self.call_listeners("progress_error", pid, err_msg, profile=profile)
+
+    def _subscribe_cb(self, answer, data):
+        entity, profile = data
+        type_ = "subscribed" if answer else "unsubscribed"
+        self.bridge.subscription(type_, str(entity.bare), profile_key=profile)
+
+    def subscribe_handler(self, type, raw_jid, profile):
+        """Called when a subsciption management signal is received"""
+        entity = jid.JID(raw_jid)
+        if type == "subscribed":
+            # this is a subscription confirmation, we just have to inform user
+            # TODO: call self.getEntityMBlog to add the new contact blogs
+            self.show_dialog(
+                _("The contact {contact} has accepted your subscription").format(
+                    contact=entity.bare
+                ),
+                _("Subscription confirmation"),
+            )
+        elif type == "unsubscribed":
+            # this is a subscription refusal, we just have to inform user
+            self.show_dialog(
+                _("The contact {contact} has refused your subscription").format(
+                    contact=entity.bare
+                ),
+                _("Subscription refusal"),
+                "error",
+            )
+        elif type == "subscribe":
+            # this is a subscriptionn request, we have to ask for user confirmation
+            # TODO: use sat.stdui.ui_contact_list to display the groups selector
+            self.show_dialog(
+                _(
+                    "The contact {contact} wants to subscribe to your presence"
+                    ".\nDo you accept ?"
+                ).format(contact=entity.bare),
+                _("Subscription confirmation"),
+                "yes/no",
+                answer_cb=self._subscribe_cb,
+                answer_data=(entity, profile),
+            )
+
+    def _debug_handler(self, action, parameters, profile):
+        if action == "widgets_dump":
+            from pprint import pformat
+            log.info("Widgets dump:\n{data}".format(data=pformat(self.widgets._widgets)))
+        else:
+            log.warning("Unknown debug action: {action}".format(action=action))
+
+
+    def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None):
+        """Show a dialog to user
+
+        Frontends must override this method
+        @param message(unicode): body of the dialog
+        @param title(unicode): title of the dialog
+        @param type(unicode): one of:
+            - "info": information dialog (callbacks not used)
+            - "warning": important information to notice (callbacks not used)
+            - "error": something went wrong (callbacks not used)
+            - "yes/no": a dialog with 2 choices (yes and no)
+        @param answer_cb(callable): method to call on answer.
+            Arguments depend on dialog type:
+            - "yes/no": argument is a boolean (True for yes)
+        @param answer_data(object): data to link on callback
+        """
+        # FIXME: misnamed method + types are not well chosen. Need to be rethought
+        raise NotImplementedError
+
+    def show_alert(self, message):
+        # FIXME: doesn't seems used anymore, to remove?
+        pass  # FIXME
+
+    def dialog_failure(self, failure):
+        log.warning("Failure: {}".format(failure))
+
+    def progress_id_handler(self, progress_id, profile):
+        """Callback used when an action result in a progress id"""
+        log.info("Progress ID received: {}".format(progress_id))
+
+    def is_hidden(self):
+        """Tells if the frontend window is hidden.
+
+        @return bool
+        """
+        raise NotImplementedError
+
+    def param_update_handler(self, name, value, namespace, profile):
+        log.debug(
+            _("param update: [%(namespace)s] %(name)s = %(value)s")
+            % {"namespace": namespace, "name": name, "value": value}
+        )
+        if (namespace, name) == ("Connection", "JabberID"):
+            log.debug(_("Changing JID to %s") % value)
+            self.profiles[profile].whoami = jid.JID(value)
+        elif (namespace, name) == ("General", C.SHOW_OFFLINE_CONTACTS):
+            self.contact_lists[profile].show_offline_contacts(C.bool(value))
+        elif (namespace, name) == ("General", C.SHOW_EMPTY_GROUPS):
+            self.contact_lists[profile].show_empty_groups(C.bool(value))
+
+    def contact_deleted_handler(self, jid_s, profile):
+        target = jid.JID(jid_s)
+        self.contact_lists[profile].remove_contact(target)
+
+    def entity_data_updated_handler(self, entity_s, key, value_raw, profile):
+        entity = jid.JID(entity_s)
+        value = data_format.deserialise(value_raw, type_check=None)
+        if key == "nicknames":
+            assert isinstance(value, list) or value is None
+            if entity in self.contact_lists[profile]:
+                self.contact_lists[profile].set_cache(entity, "nicknames", value)
+                self.call_listeners("nicknames", entity, value, profile=profile)
+        elif key == "avatar" and self.AVATARS_HANDLER:
+            assert isinstance(value, dict) or value is None
+            self.contact_lists[profile].set_cache(entity, "avatar", value)
+            self.call_listeners("avatar", entity, value, profile=profile)
+
+    def action_manager(self, action_data, callback=None, ui_show_cb=None, user_action=True,
+                      progress_cb=None, progress_eb=None, profile=C.PROF_KEY_NONE):
+        """Handle backend action
+
+        @param action_data(dict): action dict as sent by action_launch or returned by an
+            UI action
+        @param callback(None, callback): if not None, callback to use on XMLUI answer
+        @param ui_show_cb(None, callback): if not None, method to call to show the XMLUI
+        @param user_action(bool): if True, the action is a result of a user interaction
+            else the action come from backend direclty (i.e. action_new).
+            This is useful to know if the frontend can display a popup immediately (if
+            True) or if it should add it to a queue that the user can activate later.
+        @param progress_cb(None, callable): method to call when progression is finished.
+            Only make sense if a progress is expected in this action
+        @param progress_eb(None, callable): method to call when something went wrong
+            during progression.
+            Only make sense if a progress is expected in this action
+        """
+        try:
+            xmlui = action_data.pop("xmlui")
+        except KeyError:
+            pass
+        else:
+            ui = self.xmlui.create(
+                self,
+                xml_data=xmlui,
+                flags=("FROM_BACKEND",) if not user_action else None,
+                callback=callback,
+                profile=profile,
+            )
+            if ui_show_cb is None:
+                ui.show()
+            else:
+                ui_show_cb(ui)
+
+        try:
+            progress_id = action_data.pop("progress")
+        except KeyError:
+            pass
+        else:
+            if progress_cb or progress_eb:
+                self.register_progress_cbs(progress_id, progress_cb, progress_eb)
+            self.progress_id_handler(progress_id, profile)
+
+    def _action_cb(self, data, callback, callback_id, profile):
+        if callback is None:
+            self.action_manager(data, profile=profile)
+        else:
+            callback(data=data, cb_id=callback_id, profile=profile)
+
+    def action_launch(
+        self, callback_id, data=None, callback=None, profile=C.PROF_KEY_NONE
+    ):
+        """Launch a dynamic action
+
+        @param callback_id: id of the action to launch
+        @param data: data needed only for certain actions
+        @param callback(callable, None): will be called with the resut
+            if None, self.action_manager will be called
+            else the callable will be called with the following kw parameters:
+                - data: action_data
+                - cb_id: callback id
+                - profile: %(doc_profile)s
+        @param profile: %(doc_profile)s
+
+        """
+        if data is None:
+            data = dict()
+        action_cb = lambda data: self._action_cb(
+            data_format.deserialise(data), callback, callback_id, profile
+        )
+        self.bridge.action_launch(
+            callback_id, data_format.serialise(data), profile, callback=action_cb,
+            errback=self.dialog_failure
+        )
+
+    def launch_menu(
+        self,
+        menu_type,
+        path,
+        data=None,
+        callback=None,
+        security_limit=C.SECURITY_LIMIT_MAX,
+        profile=C.PROF_KEY_NONE,
+    ):
+        """Launch a menu manually
+
+        @param menu_type(unicode): type of the menu to launch
+        @param path(iterable[unicode]): path to the menu
+        @param data: data needed only for certain actions
+        @param callback(callable, None): will be called with the resut
+            if None, self.action_manager will be called
+            else the callable will be called with the following kw parameters:
+                - data: action_data
+                - cb_id: (menu_type, path) tuple
+                - profile: %(doc_profile)s
+        @param profile: %(doc_profile)s
+
+        """
+        if data is None:
+            data = dict()
+        action_cb = lambda data: self._action_cb(
+            data, callback, (menu_type, path), profile
+        )
+        self.bridge.menu_launch(
+            menu_type,
+            path,
+            data,
+            security_limit,
+            profile,
+            callback=action_cb,
+            errback=self.dialog_failure,
+        )
+
+    def disconnect(self, profile):
+        log.info("disconnecting")
+        self.call_listeners("disconnect", profile=profile)
+        self.bridge.disconnect(profile)
+
+    def on_exit(self):
+        """Must be called when the frontend is terminating"""
+        to_unplug = []
+        for profile, profile_manager in self.profiles.items():
+            if profile_manager.connected and profile_manager.autodisconnect:
+                # The user wants autodisconnection
+                self.disconnect(profile)
+            to_unplug.append(profile)
+        for profile in to_unplug:
+            self.unplug_profile(profile)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_blog.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,530 @@
+#!/usr/bin/env python3
+
+
+# helper class for making a SAT frontend
+# Copyright (C) 2011-2021 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.i18n import _, D_
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+from libervia.frontends.quick_frontend.constants import Const as C
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.frontends.tools import jid
+from libervia.backend.tools.common import data_format
+
+try:
+    # FIXME: to be removed when an acceptable solution is here
+    str("")  # XXX: unicode doesn't exist in pyjamas
+except (
+    TypeError,
+    AttributeError,
+):  # Error raised is not the same depending on pyjsbuild options
+    str = str
+
+ENTRY_CLS = None
+COMMENTS_CLS = None
+
+
+class Item(object):
+    """Manage all (meta)data of an item"""
+
+    def __init__(self, data):
+        """
+        @param data(dict): microblog data as return by bridge methods
+            if data values are not defined, set default values
+        """
+        self.id = data["id"]
+        self.title = data.get("title")
+        self.title_rich = None
+        self.title_xhtml = data.get("title_xhtml")
+        self.tags = data.get('tags', [])
+        self.content = data.get("content")
+        self.content_rich = None
+        self.content_xhtml = data.get("content_xhtml")
+        self.author = data["author"]
+        try:
+            author_jid = data["author_jid"]
+            self.author_jid = jid.JID(author_jid) if author_jid else None
+        except KeyError:
+            self.author_jid = None
+
+        self.author_verified = data.get("author_jid_verified", False)
+
+        try:
+            self.updated = float(
+                data["updated"]
+            )  # XXX: int doesn't work here (pyjamas bug)
+        except KeyError:
+            self.updated = None
+
+        try:
+            self.published = float(
+                data["published"]
+            )  # XXX: int doesn't work here (pyjamas bug)
+        except KeyError:
+            self.published = None
+
+        self.comments = data.get("comments")
+        try:
+            self.comments_service = jid.JID(data["comments_service"])
+        except KeyError:
+            self.comments_service = None
+        self.comments_node = data.get("comments_node")
+
+    # def loadComments(self):
+    #     """Load all the comments"""
+    #     index = str(main_entry.comments_count - main_entry.hidden_count)
+    #     rsm = {'max': str(main_entry.hidden_count), 'index': index}
+    #     self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm)
+
+
+class EntriesManager(object):
+    """Class which manages list of (micro)blog entries"""
+
+    def __init__(self, manager):
+        """
+        @param manager (EntriesManager, None): parent EntriesManager
+            must be None for QuickBlog (and only for QuickBlog)
+        """
+        self.manager = manager
+        if manager is None:
+            self.blog = self
+        else:
+            self.blog = manager.blog
+        self.entries = []
+        self.edit_entry = None
+
+    @property
+    def level(self):
+        """indicate how deep is this entry in the tree
+
+        if level == -1, we have a QuickBlog
+        if level == 0, we have a main item
+        else we have a comment
+        """
+        level = -1
+        manager = self.manager
+        while manager is not None:
+            level += 1
+            manager = manager.manager
+        return level
+
+    def _add_mb_items(self, items_tuple, service=None, node=None):
+        """Add Microblog items to this panel
+        update is NOT called after addition
+
+        @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mb_get
+        """
+        items, metadata = items_tuple
+        for item in items:
+            self.add_entry(item, service=service, node=node, with_update=False)
+
+    def _add_mb_items_with_comments(self, items_tuple, service=None, node=None):
+        """Add Microblog items to this panel
+        update is NOT called after addition
+
+        @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mb_get
+        """
+        items, metadata = items_tuple
+        for item, comments in items:
+            self.add_entry(item, comments, service=service, node=node, with_update=False)
+
+    def add_entry(self, item=None, comments=None, service=None, node=None,
+                 with_update=True, editable=False, edit_entry=False):
+        """Add a microblog entry
+
+        @param editable (bool): True if the entry can be modified
+        @param item (dict, None): blog item data, or None for an empty entry
+        @param comments (list, None): list of comments data if available
+        @param service (jid.JID, None): service where the entry is coming from
+        @param service (unicode, None): node hosting the entry
+        @param with_update (bool): if True, udpate is called with the new entry
+        @param edit_entry(bool): if True, will be in self.edit_entry instead of
+            self.entries, so it can be managed separately (e.g. first or last
+            entry regardless of sorting)
+        """
+        new_entry = ENTRY_CLS(self, item, comments, service=service, node=node)
+        new_entry.set_editable(editable)
+        if edit_entry:
+            self.edit_entry = new_entry
+        else:
+            self.entries.append(new_entry)
+        if with_update:
+            self.update()
+        return new_entry
+
+    def update(self, entry=None):
+        """Update the display with entries
+
+        @param entry (Entry, None): if not None, must be the new entry.
+            If None, all the items will be checked to update the display
+        """
+        # update is separated from add_entry to allow adding
+        # several entries at once, and updating at the end
+        raise NotImplementedError
+
+
+class Entry(EntriesManager):
+    """Graphical representation of an Item
+    This class must be overriden by frontends"""
+
+    def __init__(
+        self, manager, item_data=None, comments_data=None, service=None, node=None
+    ):
+        """
+        @param blog(QuickBlog): the parent QuickBlog
+        @param manager(EntriesManager): the parent EntriesManager
+        @param item_data(dict, None): dict containing the blog item data, or None for an empty entry
+        @param comments_data(list, None): list of comments data
+        """
+        assert manager is not None
+        EntriesManager.__init__(self, manager)
+        self.service = service
+        self.node = node
+        self.editable = False
+        self.reset(item_data)
+        self.blog.id2entries[self.item.id] = self
+        if self.item.comments:
+            node_tuple = (self.item.comments_service, self.item.comments_node)
+            self.blog.node2entries.setdefault(node_tuple, []).append(self)
+
+    def reset(self, item_data):
+        """Reset the entry with given data
+
+        used during init (it's a set and not a reset then)
+        or later (e.g. message sent, or cancellation of an edition
+        @param idem_data(dict, None): data as in __init__
+        """
+        if item_data is None:
+            self.new = True
+            item_data = {
+                "id": None,
+                # TODO: find a best author value
+                "author": self.blog.host.whoami.node,
+            }
+        else:
+            self.new = False
+        self.item = Item(item_data)
+        self.author_jid = self.blog.host.whoami.bare if self.new else self.item.author_jid
+        if self.author_jid is None and self.service and self.service.node:
+            self.author_jid = self.service
+        self.mode = (
+            C.ENTRY_MODE_TEXT if self.item.content_xhtml is None else C.ENTRY_MODE_XHTML
+        )
+
+    def refresh(self):
+        """Refresh the display when data have been modified"""
+        pass
+
+    def set_editable(self, editable=True):
+        """tell if the entry can be edited or not
+
+        @param editable(bool): True if the entry can be edited
+        """
+        # XXX: we don't use @property as property setter doesn't play well with pyjamas
+        raise NotImplementedError
+
+    def add_comments(self, comments_data):
+        """Add comments to this entry by calling add_entry repeatidly
+
+        @param comments_data(tuple): data as returned by mb_get_from_many*RTResults
+        """
+        # TODO: manage seperator between comments of coming from different services/nodes
+        for data in comments_data:
+            service, node, failure, comments, metadata = data
+            for comment in comments:
+                if not failure:
+                    self.add_entry(comment, service=jid.JID(service), node=node)
+                else:
+                    log.warning("getting comment failed: {}".format(failure))
+        self.update()
+
+    def send(self):
+        """Send entry according to parent QuickBlog configuration and current level"""
+
+        # keys to keep other than content*, title* and tag*
+        # FIXME: see how to avoid comments node hijacking (someone could bind his post to another post's comments node)
+        keys_to_keep = ("id", "comments", "author", "author_jid", "published")
+
+        mb_data = {}
+        for key in keys_to_keep:
+            value = getattr(self.item, key)
+            if value is not None:
+                mb_data[key] = str(value)
+
+        for prefix in ("content", "title"):
+            for suffix in ("", "_rich", "_xhtml"):
+                name = "{}{}".format(prefix, suffix)
+                value = getattr(self.item, name)
+                if value is not None:
+                    mb_data[name] = value
+
+        mb_data['tags'] = self.item.tags
+
+        if self.blog.new_message_target not in (C.PUBLIC, C.GROUP):
+            raise NotImplementedError
+
+        if self.level == 0:
+            mb_data["allow_comments"] = True
+
+        if self.blog.new_message_target == C.GROUP:
+            mb_data['groups'] = list(self.blog.targets)
+
+        self.blog.host.bridge.mb_send(
+            str(self.service or ""),
+            self.node or "",
+            data_format.serialise(mb_data),
+            profile=self.blog.profile,
+        )
+
+    def delete(self):
+        """Remove this Entry from parent manager
+
+        This doesn't delete any entry in PubSub, just locally
+        all children entries will be recursively removed too
+        """
+        # XXX: named delete and not remove to avoid conflict with pyjamas
+        log.debug("deleting entry {}".format("EDIT ENTRY" if self.new else self.item.id))
+        for child in self.entries:
+            child.delete()
+        try:
+            self.manager.entries.remove(self)
+        except ValueError:
+            if self != self.manager.edit_entry:
+                log.error("Internal Error: entry not found in manager")
+            else:
+                self.manager.edit_entry = None
+        if not self.new:
+            # we must remove references to self
+            # in QuickBlog's dictionary
+            del self.blog.id2entries[self.item.id]
+            if self.item.comments:
+                comments_tuple = (self.item.comments_service, self.item.comments_node)
+                other_entries = self.blog.node2entries[comments_tuple].remove(self)
+                if not other_entries:
+                    del self.blog.node2entries[comments_tuple]
+
+    def retract(self):
+        """Retract this item from microblog node
+
+        if there is a comments node, it will be purged too
+        """
+        # TODO: manage several comments nodes case.
+        if self.item.comments:
+            self.blog.host.bridge.ps_node_delete(
+                str(self.item.comments_service) or "",
+                self.item.comments_node,
+                profile=self.blog.profile,
+            )
+        self.blog.host.bridge.mb_retract(
+            str(self.service or ""),
+            self.node or "",
+            self.item.id,
+            profile=self.blog.profile,
+        )
+
+
+class QuickBlog(EntriesManager, quick_widgets.QuickWidget):
+    def __init__(self, host, targets, profiles=None):
+        """Panel used to show microblog
+
+        @param targets (tuple(unicode)): contact groups displayed in this panel.
+            If empty, show all microblogs from all contacts. targets is also used
+            to know where to send new messages.
+        """
+        EntriesManager.__init__(self, None)
+        self.id2entries = {}  # used to find an entry with it's item id
+        # must be kept up-to-date by Entry
+        self.node2entries = {}  # same as above, values are lists in case of
+        # two entries link to the same comments node
+        if not targets:
+            targets = ()  # XXX: we use empty tuple instead of None to workaround a pyjamas bug
+            quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE)
+            self._targets_type = C.ALL
+        else:
+            assert isinstance(targets[0], str)
+            quick_widgets.QuickWidget.__init__(self, host, targets[0], C.PROF_KEY_NONE)
+            for target in targets[1:]:
+                assert isinstance(target, str)
+                self.add_target(target)
+            self._targets_type = C.GROUP
+
+    @property
+    def new_message_target(self):
+        if self._targets_type == C.ALL:
+            return C.PUBLIC
+        elif self._targets_type == C.GROUP:
+            return C.GROUP
+        else:
+            raise ValueError("Unkown targets type")
+
+    def __str__(self):
+        return "Blog Widget [target: {}, profile: {}]".format(
+            ", ".join(self.targets), self.profile
+        )
+
+    def _get_results_cb(self, data, rt_session):
+        remaining, results = data
+        log.debug(
+            "Got {got_len} results, {rem_len} remaining".format(
+                got_len=len(results), rem_len=remaining
+            )
+        )
+        for result in results:
+            service, node, failure, items_data, metadata = result
+            for item_data in items_data:
+                item_data[0] = data_format.deserialise(item_data[0])
+                for item_metadata in item_data[1]:
+                    item_metadata[3] = [data_format.deserialise(i) for i in item_metadata[3]]
+            if not failure:
+                self._add_mb_items_with_comments((items_data, metadata),
+                                             service=jid.JID(service))
+
+        self.update()
+        if remaining:
+            self._get_results(rt_session)
+
+    def _get_results_eb(self, failure):
+        log.warning("microblog get_from_many error: {}".format(failure))
+
+    def _get_results(self, rt_session):
+        """Manage results from mb_get_from_many RT Session
+
+        @param rt_session(str): session id as returned by mb_get_from_many
+        """
+        self.host.bridge.mb_get_from_many_with_comments_rt_result(
+            rt_session,
+            profile=self.profile,
+            callback=lambda data: self._get_results_cb(data, rt_session),
+            errback=self._get_results_eb,
+        )
+
+    def get_all(self):
+        """Get all (micro)blogs from self.targets"""
+
+        def got_session(rt_session):
+            self._get_results(rt_session)
+
+        if self._targets_type in (C.ALL, C.GROUP):
+            targets = tuple(self.targets) if self._targets_type is C.GROUP else ()
+            self.host.bridge.mb_get_from_many_with_comments(
+                self._targets_type,
+                targets,
+                10,
+                10,
+                {},
+                {"subscribe": C.BOOL_TRUE},
+                profile=self.profile,
+                callback=got_session,
+            )
+            own_pep = self.host.whoami.bare
+            self.host.bridge.mb_get_from_many_with_comments(
+                C.JID,
+                (str(own_pep),),
+                10,
+                10,
+                {},
+                {},
+                profile=self.profile,
+                callback=got_session,
+            )
+        else:
+            raise NotImplementedError(
+                "{} target type is not managed".format(self._targets_type)
+            )
+
+    def is_jid_accepted(self, jid_):
+        """Tell if a jid is actepted and must be shown in this panel
+
+        @param jid_(jid.JID): jid to check
+        @return: True if the jid is accepted
+        """
+        if self._targets_type == C.ALL:
+            return True
+        assert self._targets_type is C.GROUP  # we don't manage other types for now
+        for group in self.targets:
+            if self.host.contact_lists[self.profile].is_entity_in_group(jid_, group):
+                return True
+        return False
+
+    def add_entry_if_accepted(self, service, node, mb_data, groups, profile):
+        """add entry to this panel if it's acceptable
+
+        This method check if the entry is new or an update,
+        if it below to a know node, or if it acceptable anyway
+        @param service(jid.JID): jid of the emitting pubsub service
+        @param node(unicode): node identifier
+        @param mb_data: microblog data
+        @param groups(list[unicode], None): groups which can receive this entry
+            None to accept everything
+        @param profile: %(doc_profile)s
+        """
+        try:
+            entry = self.id2entries[mb_data["id"]]
+        except KeyError:
+            # The entry is new
+            try:
+                parent_entries = self.node2entries[(service, node)]
+            except:
+                # The node is unknown,
+                # we need to check that we can accept the entry
+                if (
+                    self.is_jid_accepted(service)
+                    or (
+                        groups is None
+                        and service == self.host.profiles[self.profile].whoami.bare
+                    )
+                    or (groups and groups.intersection(self.targets))
+                ):
+                    self.add_entry(mb_data, service=service, node=node)
+            else:
+                # the entry is a comment in a known node
+                for parent_entry in parent_entries:
+                    parent_entry.add_entry(mb_data, service=service, node=node)
+        else:
+            # The entry exist, it's an update
+            entry.reset(mb_data)
+            entry.refresh()
+
+    def delete_entry_if_present(self, service, node, item_id, profile):
+        """Delete and entry if present in this QuickBlog
+
+        @param sender(jid.JID): jid of the entry sender
+        @param mb_data: microblog data
+        @param service(jid.JID): sending service
+        @param node(unicode): hosting node
+        """
+        try:
+            entry = self.id2entries[item_id]
+        except KeyError:
+            pass
+        else:
+            entry.delete()
+
+
+def register_class(type_, cls):
+    global ENTRY_CLS, COMMENTS_CLS
+    if type_ == "ENTRY":
+        ENTRY_CLS = cls
+    elif type == "COMMENT":
+        COMMENTS_CLS = cls
+    else:
+        raise ValueError("type_ should be ENTRY or COMMENT")
+    if COMMENTS_CLS is None:
+        COMMENTS_CLS = ENTRY_CLS
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_chat.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,941 @@
+#!/usr/bin/env python3
+
+# helper class for making a SàT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from libervia.backend.core import exceptions
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.frontends.quick_frontend.constants import Const as C
+from collections import OrderedDict
+from libervia.frontends.tools import jid
+import time
+
+
+log = getLogger(__name__)
+
+
+ROOM_USER_JOINED = "ROOM_USER_JOINED"
+ROOM_USER_LEFT = "ROOM_USER_LEFT"
+ROOM_USER_MOVED = (ROOM_USER_JOINED, ROOM_USER_LEFT)
+
+# from datetime import datetime
+
+# FIXME: day_format need to be settable (i18n)
+
+
+class Message:
+    """Message metadata"""
+
+    def __init__(
+            self, parent, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra,
+            profile):
+        self.parent = parent
+        self.profile = profile
+        self.uid = uid
+        self.timestamp = timestamp
+        self.from_jid = from_jid
+        self.to_jid = to_jid
+        self.message = msg
+        self.subject = subject
+        self.type = type_
+        self.extra = extra
+        self.nick = self.get_nick(from_jid)
+        self._status = None
+        # own_mess is True if message was sent by profile's jid
+        self.own_mess = (
+            (from_jid.resource == self.parent.nick)
+            if self.parent.type == C.CHAT_GROUP
+            else (from_jid.bare == self.host.profiles[profile].whoami.bare)
+        )
+        # is user mentioned here ?
+        if self.parent.type == C.CHAT_GROUP and not self.own_mess:
+            for m in msg.values():
+                if self.parent.nick.lower() in m.lower():
+                    self._mention = True
+                    break
+        self.handle_me()
+        self.widgets = set()  # widgets linked to this message
+
+    def __str__(self):
+        return "Message<{mess_type}>  [{time}]{nick}> {message}".format(
+            mess_type=self.type,
+            time=self.time_text,
+            nick=self.nick,
+            message=self.main_message)
+
+    def __contains__(self, item):
+        return hasattr(self, item) or item in self.extra
+
+    @property
+    def host(self):
+        return self.parent.host
+
+    @property
+    def info_type(self):
+        return self.extra.get("info_type")
+
+    @property
+    def mention(self):
+        try:
+            return self._mention
+        except AttributeError:
+            return False
+
+    @property
+    def history(self):
+        """True if message come from history"""
+        return self.extra.get("history", False)
+
+    @property
+    def main_message(self):
+        """currently displayed message"""
+        if self.parent.lang in self.message:
+            self.selected_lang = self.parent.lang
+            return self.message[self.parent.lang]
+        try:
+            self.selected_lang = ""
+            return self.message[""]
+        except KeyError:
+            try:
+                lang, mess = next(iter(self.message.items()))
+                self.selected_lang = lang
+                return mess
+            except StopIteration:
+                if not self.attachments:
+                    # we may have empty messages if we have attachments
+                    log.error("Can't find message for uid {}".format(self.uid))
+                return ""
+
+    @property
+    def main_message_xhtml(self):
+        """rich message"""
+        xhtml = {k: v for k, v in self.extra.items() if "html" in k}
+        if xhtml:
+            # FIXME: we only return first found value for now
+            return next(iter(xhtml.values()))
+
+    @property
+    def time_text(self):
+        """Return timestamp in a nicely formatted way"""
+        # if the message was sent before today, we print the full date
+        timestamp = time.localtime(self.timestamp)
+        time_format = "%c" if timestamp < self.parent.day_change else "%H:%M"
+        return time.strftime(time_format, timestamp)
+
+    @property
+    def avatar(self):
+        """avatar data or None if no avatar is found"""
+        entity = self.from_jid
+        contact_list = self.host.contact_lists[self.profile]
+        try:
+            return contact_list.getCache(entity, "avatar")
+        except (exceptions.NotFound, KeyError):
+            # we don't check the result as the avatar listener will be called
+            self.host.bridge.avatar_get(entity, True, self.profile)
+            return None
+
+    @property
+    def encrypted(self):
+        return self.extra.get("encrypted", False)
+
+    def get_nick(self, entity):
+        """Return nick of an entity when possible"""
+        contact_list = self.host.contact_lists[self.profile]
+        if self.type == C.MESS_TYPE_INFO and self.info_type in ROOM_USER_MOVED:
+            try:
+                return self.extra["user_nick"]
+            except KeyError:
+                log.error("extra data is missing user nick for uid {}".format(self.uid))
+                return ""
+        # FIXME: converted get_specials to list for pyjamas
+        if self.parent.type == C.CHAT_GROUP or entity in list(
+            contact_list.get_specials(C.CONTACT_SPECIAL_GROUP)
+        ):
+            return entity.resource or ""
+        if entity.bare in contact_list:
+
+            try:
+                nicknames = contact_list.getCache(entity, "nicknames")
+            except (exceptions.NotFound, KeyError):
+                # we check result as listener will be called
+                self.host.bridge.identity_get(
+                    entity.bare, ["nicknames"], True, self.profile)
+                return entity.node or entity
+
+            if nicknames:
+                return nicknames[0]
+            else:
+                return (
+                    contact_list.getCache(entity, "name", default=None)
+                    or entity.node
+                    or entity
+                )
+
+        return entity.node or entity
+
+    @property
+    def status(self):
+        return self._status
+
+    @status.setter
+    def status(self, status):
+        if status != self._status:
+            self._status = status
+            for w in self.widgets:
+                w.update({"status": status})
+
+    def handle_me(self):
+        """Check if messages starts with "/me " and change them if it is the case
+
+        if several messages (different languages) are presents, they all need to start with "/me "
+        """
+        # TODO: XHTML-IM /me are not handled
+        me = False
+        # we need to check /me for every message
+        for m in self.message.values():
+            if m.startswith("/me "):
+                me = True
+            else:
+                me = False
+                break
+        if me:
+            self.type = C.MESS_TYPE_INFO
+            self.extra["info_type"] = "me"
+            nick = self.nick
+            for lang, mess in self.message.items():
+                self.message[lang] = "* " + nick + mess[3:]
+
+    @property
+    def attachments(self):
+        return self.extra.get(C.KEY_ATTACHMENTS)
+
+
+class MessageWidget:
+    """Base classe for widgets"""
+    # This class does nothing and is only used to have a common ancestor
+
+    pass
+
+
+class Occupant:
+    """Occupant metadata"""
+
+    def __init__(self, parent, data, profile):
+        self.parent = parent
+        self.profile = profile
+        self.nick = data["nick"]
+        self._entity = data.get("entity")
+        self.affiliation = data["affiliation"]
+        self.role = data["role"]
+        self.widgets = set()  # widgets linked to this occupant
+        self._state = None
+
+    @property
+    def data(self):
+        """reconstruct data dict from attributes"""
+        data = {}
+        data["nick"] = self.nick
+        if self._entity is not None:
+            data["entity"] = self._entity
+        data["affiliation"] = self.affiliation
+        data["role"] = self.role
+        return data
+
+    @property
+    def jid(self):
+        """jid in the room"""
+        return jid.JID("{}/{}".format(self.parent.target.bare, self.nick))
+
+    @property
+    def real_jid(self):
+        """real jid if known else None"""
+        return self._entity
+
+    @property
+    def host(self):
+        return self.parent.host
+
+    @property
+    def state(self):
+        return self._state
+
+    @state.setter
+    def state(self, new_state):
+        if new_state != self._state:
+            self._state = new_state
+            for w in self.widgets:
+                w.update({"state": new_state})
+
+    def update(self, update_dict=None):
+        for w in self.widgets:
+            w.update(update_dict)
+
+
+class QuickChat(quick_widgets.QuickWidget):
+    visible_states = ["chat_state"]  # FIXME: to be removed, used only in quick_games
+
+    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None,
+                 subject=None, statuses=None, profiles=None):
+        """
+        @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for
+                      chat à la IRC
+        """
+        self.lang = ""  # default language to use for messages
+        quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles)
+        assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP)
+        self.current_target = target
+        self.type = type_
+        self.encrypted = False  # True if this session is currently encrypted
+        self._locked = False
+        # True when resync is in progress, avoid resynchronising twice when resync is called
+        # and history is still being updated. For internal use only
+        self._resync_lock = False
+        self.set_locked()
+        if type_ == C.CHAT_GROUP:
+            if target.resource:
+                raise exceptions.InternalError(
+                    "a group chat entity can't have a resource"
+                )
+            if nick is None:
+                raise exceptions.InternalError("nick must not be None for group chat")
+
+            self.nick = nick
+            self.occupants = {}
+            self.set_occupants(occupants)
+        else:
+            if occupants is not None or nick is not None:
+                raise exceptions.InternalError(
+                    "only group chat can have occupants or nick"
+                )
+        self.messages = OrderedDict()  # key: uid, value: Message instance
+        self.games = {}  # key=game name (unicode), value=instance of quick_games.RoomGame
+        self.subject = subject
+        self.statuses = set(statuses or [])
+        lt = time.localtime()
+        self.day_change = (
+            lt.tm_year,
+            lt.tm_mon,
+            lt.tm_mday,
+            0,
+            0,
+            0,
+            lt.tm_wday,
+            lt.tm_yday,
+            lt.tm_isdst,
+        )  # struct_time of day changing time
+        if self.host.AVATARS_HANDLER:
+            self.host.addListener("avatar", self.on_avatar, profiles)
+
+    def set_locked(self):
+        """Set locked flag
+
+        To be set when we are waiting for history/search
+        """
+        # FIXME: we don't use getter/setter here because of pyjamas
+        # TODO: use proper getter/setter once we get rid of pyjamas
+        if self._locked:
+            log.warning("{wid} is already locked!".format(wid=self))
+            return
+        self._locked = True
+        # message_new signals are cached when locked
+        self._cache = OrderedDict()
+        log.debug("{wid} is now locked".format(wid=self))
+
+    def set_unlocked(self):
+        if not self._locked:
+            log.debug("{wid} was already unlocked".format(wid=self))
+            return
+        self._locked = False
+        for uid, data in self._cache.items():
+            if uid not in self.messages:
+                self.message_new(*data)
+            else:
+                log.debug("discarding message already in history: {data}, ".format(data=data))
+        del self._cache
+        log.debug("{wid} is now unlocked".format(wid=self))
+
+    def post_init(self):
+        """Method to be called by frontend after widget is initialised
+
+        handle the display of history and subject
+        """
+        self.history_print(profile=self.profile)
+        if self.subject is not None:
+            self.set_subject(self.subject)
+        if self.host.ENCRYPTION_HANDLERS:
+            self.get_encryption_state()
+
+    def on_delete(self):
+        if self.host.AVATARS_HANDLER:
+            self.host.removeListener("avatar", self.on_avatar)
+
+    @property
+    def contact_list(self):
+        return self.host.contact_lists[self.profile]
+
+    @property
+    def message_widgets_rev(self):
+        """Return the history of MessageWidget in reverse chronological order
+
+        Must be implemented by frontend
+        """
+        raise NotImplementedError
+
+    ## synchornisation handling ##
+
+    @quick_widgets.QuickWidget.sync.setter
+    def sync(self, state):
+        quick_widgets.QuickWidget.sync.fset(self, state)
+        if not state:
+            self.set_locked()
+
+    def _resync_complete(self):
+        self.sync = True
+        self._resync_lock = False
+
+    def occupants_clear(self):
+        """Remove all occupants
+
+        Must be overridden by frontends to clear their own representations of occupants
+        """
+        self.occupants.clear()
+
+    def resync(self):
+        if self._resync_lock:
+            return
+        self._resync_lock = True
+        log.debug("resynchronising {self}".format(self=self))
+        for mess in reversed(list(self.messages.values())):
+            if mess.type == C.MESS_TYPE_INFO:
+                continue
+            last_message = mess
+            break
+        else:
+            # we have no message yet, we can get normal history
+            self.history_print(callback=self._resync_complete, profile=self.profile)
+            return
+        if self.type == C.CHAT_GROUP:
+            self.occupants_clear()
+            self.host.bridge.muc_occupants_get(
+                str(self.target), self.profile, callback=self.update_occupants,
+                errback=log.error)
+        self.history_print(
+            size=C.HISTORY_LIMIT_NONE,
+            filters={'timestamp_start': last_message.timestamp},
+            callback=self._resync_complete,
+            profile=self.profile)
+
+    ## Widget management ##
+
+    def __str__(self):
+        return "Chat Widget [target: {}, type: {}, profile: {}]".format(
+            self.target, self.type, self.profile
+        )
+
+    @staticmethod
+    def get_widget_hash(target, profiles):
+        profile = list(profiles)[0]
+        return profile + "\n" + str(target.bare)
+
+    @staticmethod
+    def get_private_hash(target, profile):
+        """Get unique hash for private conversations
+
+        This method should be used with force_hash to get unique widget for private MUC conversations
+        """
+        return (str(profile), target)
+
+    def add_target(self, target):
+        super(QuickChat, self).add_target(target)
+        if target.resource:
+            self.current_target = (
+                target
+            )  # FIXME: tmp, must use resource priority throught contactList instead
+
+    def recreate_args(self, args, kwargs):
+        """copy important attribute for a new widget"""
+        kwargs["type_"] = self.type
+        if self.type == C.CHAT_GROUP:
+            kwargs["occupants"] = {o.nick: o.data for o in self.occupants.values()}
+        kwargs["subject"] = self.subject
+        try:
+            kwargs["nick"] = self.nick
+        except AttributeError:
+            pass
+
+    def on_private_created(self, widget):
+        """Method called when a new widget for private conversation (MUC) is created"""
+        raise NotImplementedError
+
+    def get_or_create_private_widget(self, entity):
+        """Create a widget for private conversation, or get it if it already exists
+
+        @param entity: full jid of the target
+        """
+        return self.host.widgets.get_or_create_widget(
+            QuickChat,
+            entity,
+            type_=C.CHAT_ONE2ONE,
+            force_hash=self.get_private_hash(self.profile, entity),
+            on_new_widget=self.on_private_created,
+            profile=self.profile,
+        )  # we force hash to have a new widget, not this one again
+
+    @property
+    def target(self):
+        if self.type == C.CHAT_GROUP:
+            return self.current_target.bare
+        return self.current_target
+
+    ## occupants ##
+
+    def set_occupants(self, occupants):
+        """Set the whole list of occupants"""
+        assert len(self.occupants) == 0
+        for nick, data in occupants.items():
+            # XXX: this log is disabled because it's really too verbose
+            #      but kept commented as it may be useful for debugging
+            # log.debug(u"adding occupant {nick} to {room}".format(
+            #     nick=nick, room=self.target))
+            self.occupants[nick] = Occupant(self, data, self.profile)
+
+    def update_occupants(self, occupants):
+        """Update occupants list
+
+        In opposition to set_occupants, this only add missing occupants and remove
+        occupants who have left
+        """
+        # FIXME: occupants with modified status are not handled
+        local_occupants = set(self.occupants)
+        updated_occupants = set(occupants)
+        left_occupants = local_occupants - updated_occupants
+        joined_occupants = updated_occupants - local_occupants
+        log.debug("updating occupants for {room}:\n"
+                  "left: {left_occupants}\n"
+                  "joined: {joined_occupants}"
+                  .format(room=self.target,
+                          left_occupants=", ".join(left_occupants),
+                          joined_occupants=", ".join(joined_occupants)))
+        for nick in left_occupants:
+            self.removeUser(occupants[nick])
+        for nick in joined_occupants:
+            self.addUser(occupants[nick])
+
+    def addUser(self, occupant_data):
+        """Add user if it is not in the group list"""
+        occupant = Occupant(self, occupant_data, self.profile)
+        self.occupants[occupant.nick] = occupant
+        return occupant
+
+    def removeUser(self, occupant_data):
+        """Remove a user from the group list"""
+        nick = occupant_data["nick"]
+        try:
+            occupant = self.occupants.pop(nick)
+        except KeyError:
+            log.warning("Trying to remove an unknown occupant: {}".format(nick))
+        else:
+            return occupant
+
+    def set_user_nick(self, nick):
+        """Set the nick of the user, usefull for e.g. change the color of the user"""
+        self.nick = nick
+
+    def change_user_nick(self, old_nick, new_nick):
+        """Change nick of a user in group list"""
+        log.info("{old} is now known as {new} in room {room_jid}".format(
+            old = old_nick,
+            new = new_nick,
+            room_jid = self.target))
+
+    ## Messages ##
+
+    def manage_message(self, entity, mess_type):
+        """Tell if this chat widget manage an entity and message type couple
+
+        @param entity (jid.JID): (full) jid of the sending entity
+        @param mess_type (str): message type as given by message_new
+        @return (bool): True if this Chat Widget manage this couple
+        """
+        if self.type == C.CHAT_GROUP:
+            if (
+                mess_type in (C.MESS_TYPE_GROUPCHAT, C.MESS_TYPE_INFO)
+                and self.target == entity.bare
+            ):
+                return True
+        else:
+            if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets:
+                return True
+        return False
+
+    def update_history(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile="@NONE@"):
+        """Called when history need to be recreated
+
+        Remove all message from history then call history_print
+        Must probably be overriden by frontend to clear widget
+        @param size (int): number of messages
+        @param filters (str): patterns to filter the history results
+        @param profile (str): %(doc_profile)s
+        """
+        self.set_locked()
+        self.messages.clear()
+        self.history_print(size, filters, profile=profile)
+
+    def _on_history_printed(self):
+        """Method called when history is printed (or failed)
+
+        unlock the widget, and can be used to refresh or scroll down
+        the focus after the history is printed
+        """
+        self.set_unlocked()
+
+    def history_print(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, callback=None,
+                     profile="@NONE@"):
+        """Print the current history
+
+        Note: self.set_unlocked will be called once history is printed
+        @param size (int): number of messages
+        @param search (str): pattern to filter the history results
+        @param callback(callable, None): method to call when history has been printed
+        @param profile (str): %(doc_profile)s
+        """
+        if filters is None:
+            filters = {}
+        if size == 0:
+            log.debug("Empty history requested, skipping")
+            self._on_history_printed()
+            return
+        log_msg = _("now we print the history")
+        if size != C.HISTORY_LIMIT_DEFAULT:
+            log_msg += _(" ({} messages)".format(size))
+        log.debug(log_msg)
+
+        if self.type == C.CHAT_ONE2ONE:
+            special = self.host.contact_lists[self.profile].getCache(
+                self.target, C.CONTACT_SPECIAL, create_if_not_found=True, default=None
+            )
+            if special == C.CONTACT_SPECIAL_GROUP:
+                # we have a private conversation
+                # so we need full jid for the history
+                # (else we would get history from group itself)
+                # and to filter out groupchat message
+                target = self.target
+                filters["not_types"] = C.MESS_TYPE_GROUPCHAT
+            else:
+                target = self.target.bare
+        else:
+            # groupchat
+            target = self.target.bare
+            # FIXME: info not handled correctly
+            filters["types"] = C.MESS_TYPE_GROUPCHAT
+
+        self.history_filters = filters
+
+        def _history_get_cb(history):
+            # day_format = "%A, %d %b %Y"  # to display the day change
+            # previous_day = datetime.now().strftime(day_format)
+            # message_day = datetime.fromtimestamp(timestamp).strftime(self.day_format)
+            # if previous_day != message_day:
+            #     self.print_day_change(message_day)
+            #     previous_day = message_day
+            for data in history:
+                uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data
+                from_jid = jid.JID(from_jid)
+                to_jid = jid.JID(to_jid)
+                extra = data_format.deserialise(extra_s)
+                # if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or
+                #    (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)):
+                #     continue
+                extra["history"] = True
+                self.messages[uid] = Message(
+                    self,
+                    uid,
+                    timestamp,
+                    from_jid,
+                    to_jid,
+                    message,
+                    subject,
+                    type_,
+                    extra,
+                    profile,
+                )
+            self._on_history_printed()
+            if callback is not None:
+                callback()
+
+        def _history_get_eb(err):
+            log.error(_("Can't get history: {}").format(err))
+            self._on_history_printed()
+            if callback is not None:
+                callback()
+
+        self.host.bridge.history_get(
+            str(self.host.profiles[profile].whoami.bare),
+            str(target),
+            size,
+            True,
+            {k: str(v) for k,v in filters.items()},
+            profile,
+            callback=_history_get_cb,
+            errback=_history_get_eb,
+        )
+
+    def message_encryption_get_cb(self, session_data):
+        if session_data:
+            session_data = data_format.deserialise(session_data)
+            self.message_encryption_started(session_data)
+
+    def message_encryption_get_eb(self, failure_):
+        log.error(_("Can't get encryption state: {reason}").format(reason=failure_))
+
+    def get_encryption_state(self):
+        """Retrieve encryption state with current target.
+
+        Once state is retrieved, default message_encryption_started will be called if
+        suitable
+        """
+        if self.type == C.CHAT_GROUP:
+            return
+        self.host.bridge.message_encryption_get(str(self.target.bare), self.profile,
+                                              callback=self.message_encryption_get_cb,
+                                              errback=self.message_encryption_get_eb)
+
+
+    def message_new(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra,
+                   profile):
+        if self._locked:
+            self._cache[uid] = (
+                uid,
+                timestamp,
+                from_jid,
+                to_jid,
+                msg,
+                subject,
+                type_,
+                extra,
+                profile,
+            )
+            return
+
+        if ((not msg and not subject and not extra[C.KEY_ATTACHMENTS]
+             and type_ != C.MESS_TYPE_INFO)):
+            log.warning("Received an empty message for uid {}".format(uid))
+            return
+
+        if self.type == C.CHAT_GROUP:
+            if to_jid.resource and type_ != C.MESS_TYPE_GROUPCHAT:
+                # we have a private message, we forward it to a private conversation
+                # widget
+                chat_widget = self.get_or_create_private_widget(to_jid)
+                chat_widget.message_new(
+                    uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile
+                )
+                return
+            if type_ == C.MESS_TYPE_INFO:
+                try:
+                    info_type = extra["info_type"]
+                except KeyError:
+                    pass
+                else:
+                    user_data = {
+                        k[5:]: v for k, v in extra.items() if k.startswith("user_")
+                    }
+                    if info_type == ROOM_USER_JOINED:
+                        self.addUser(user_data)
+                    elif info_type == ROOM_USER_LEFT:
+                        self.removeUser(user_data)
+
+        message = Message(
+            self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile
+        )
+        self.messages[uid] = message
+
+        if "received_timestamp" in extra:
+            log.warning("Delayed message received after history, this should not happen")
+        self.create_message(message)
+
+    def message_encryption_started(self, session_data):
+        self.encrypted = True
+        log.debug(_("message encryption started with {target} using {encryption}").format(
+            target=self.target, encryption=session_data['name']))
+
+    def message_encryption_stopped(self, session_data):
+        self.encrypted = False
+        log.debug(_("message encryption stopped with {target} (was using {encryption})")
+                 .format(target=self.target, encryption=session_data['name']))
+
+    def create_message(self, message, append=False):
+        """Must be implemented by frontend to create and show a new message widget
+
+        This is only called on message_new, not on history.
+        You need to override history_print to handle the later
+        @param message(Message): message data
+        """
+        raise NotImplementedError
+
+    def is_user_moved(self, message):
+        """Return True if message is a user left/joined message
+
+        @param message(Message): message to check
+        @return (bool): True is message is user moved info message
+        """
+        if message.type != C.MESS_TYPE_INFO:
+            return False
+        try:
+            info_type = message.extra["info_type"]
+        except KeyError:
+            return False
+        else:
+            return info_type in ROOM_USER_MOVED
+
+    def handle_user_moved(self, message):
+        """Check if this message is a UserMoved one, and merge it when possible
+
+        "merge it" means that info message indicating a user joined/left will be
+        grouped if no other non-info messages has been sent since
+        @param message(Message): message to check
+        @return (bool): True if this message has been merged
+            if True, a new MessageWidget must not be created and appended to history
+        """
+        if self.is_user_moved(message):
+            for wid in self.message_widgets_rev:
+                # we merge in/out messages if no message was sent meanwhile
+                if not isinstance(wid, MessageWidget):
+                    continue
+                elif wid.mess_data.type != C.MESS_TYPE_INFO:
+                    return False
+                elif (
+                    wid.info_type in ROOM_USER_MOVED
+                    and wid.mess_data.nick == message.nick
+                ):
+                    try:
+                        count = wid.reentered_count
+                    except AttributeError:
+                        count = wid.reentered_count = 1
+                    nick = wid.mess_data.nick
+                    if message.info_type == ROOM_USER_LEFT:
+                        wid.message = _("<= {nick} has left the room ({count})").format(
+                            nick=nick, count=count
+                        )
+                    else:
+                        wid.message = _(
+                            "<=> {nick} re-entered the room ({count})"
+                        ).format(nick=nick, count=count)
+                        wid.reentered_count += 1
+                    return True
+        return False
+
+    def print_day_change(self, day):
+        """Display the day on a new line.
+
+        @param day(unicode): day to display (or not if this method is not overwritten)
+        """
+        # FIXME: not called anymore after refactoring
+        pass
+
+    ## Room ##
+
+    def set_subject(self, subject):
+        """Set title for a group chat"""
+        if self.type != C.CHAT_GROUP:
+            raise exceptions.InternalError(
+                "trying to set subject for a non group chat window"
+            )
+        self.subject = subject
+
+    def change_subject(self, new_subject):
+        """Change the subject of the room
+
+        This change the subject on the room itself (i.e. via XMPP),
+        while set_subject change the subject of this widget
+        """
+        self.host.bridge.muc_subject(str(self.target), new_subject, self.profile)
+
+    def add_game_panel(self, widget):
+        """Insert a game panel to this Chat dialog.
+
+        @param widget (Widget): the game panel
+        """
+        raise NotImplementedError
+
+    def remove_game_panel(self, widget):
+        """Remove the game panel from this Chat dialog.
+
+        @param widget (Widget): the game panel
+        """
+        raise NotImplementedError
+
+    def update(self, entity=None):
+        """Update one or all entities.
+
+        @param entity (jid.JID): entity to update
+        """
+        # FIXME: to remove ?
+        raise NotImplementedError
+
+    ## events ##
+
+    def on_chat_state(self, from_jid, state, profile):
+        """A chat state has been received"""
+        if self.type == C.CHAT_GROUP:
+            nick = from_jid.resource
+            try:
+                self.occupants[nick].state = state
+            except KeyError:
+                log.warning(
+                    "{nick} not found in {room}, ignoring new chat state".format(
+                        nick=nick, room=self.target.bare
+                    )
+                )
+
+    def on_message_state(self, uid, status, profile):
+        try:
+            mess_data = self.messages[uid]
+        except KeyError:
+            pass
+        else:
+            mess_data.status = status
+
+    def on_avatar(self, entity, avatar_data, profile):
+        if self.type == C.CHAT_GROUP:
+            if entity.bare == self.target:
+                try:
+                    self.occupants[entity.resource].update({"avatar": avatar_data})
+                except KeyError:
+                    # can happen for a message in history where the
+                    # entity is not here anymore
+                    pass
+
+                for m in list(self.messages.values()):
+                    if m.nick == entity.resource:
+                        for w in m.widgets:
+                            w.update({"avatar": avatar_data})
+        else:
+            if (
+                entity.bare == self.target.bare
+                or entity.bare == self.host.profiles[profile].whoami.bare
+            ):
+                log.info("avatar updated for {}".format(entity))
+                for m in list(self.messages.values()):
+                    if m.from_jid.bare == entity.bare:
+                        for w in m.widgets:
+                            w.update({"avatar": avatar_data})
+
+
+quick_widgets.register(QuickChat)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_contact_list.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1113 @@
+#!/usr/bin/env python3
+
+# helper class for making a SàT frontend contact lists
+# Copyright (C) 2009-2021 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/>.
+
+"""Contact List handling multi profiles at once,
+    should replace quick_contact_list module in the future"""
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.frontends.quick_frontend.quick_widgets import QuickWidget
+from libervia.frontends.quick_frontend.constants import Const as C
+from libervia.frontends.tools import jid
+from collections import OrderedDict
+
+log = getLogger(__name__)
+
+try:
+    # FIXME: to be removed when an acceptable solution is here
+    str("")  # XXX: unicode doesn't exist in pyjamas
+except (TypeError, AttributeError):  # Error raised is not the same depending on
+    # pyjsbuild options
+    # XXX: pyjamas' max doesn't support key argument, so we implement it ourself
+    pyjamas_max = max
+
+    def max(iterable, key):
+        iter_cpy = list(iterable)
+        iter_cpy.sort(key=key)
+        return pyjamas_max(iter_cpy)
+
+    # next doesn't exist in pyjamas
+    def next(iterable, *args):
+        try:
+            return iterable.__next__()
+        except StopIteration as e:
+            if args:
+                return args[0]
+            raise e
+
+
+handler = None
+
+
+class ProfileContactList(object):
+    """Contact list data for a single profile"""
+
+    def __init__(self, profile):
+        self.host = handler.host
+        self.profile = profile
+        # contain all jids in roster or not,
+        # bare jids as keys, resources are used in data
+        # XXX: we don't mutualise cache, as values may differ
+        # for different profiles (e.g. directed presence)
+        self._cache = {}
+
+        # special entities (groupchat, gateways, etc)
+        # may be bare or full jid
+        self._specials = set()
+
+        # group data contain jids in groups and misc frontend data
+        # None key is used for jids with no group
+        self._groups = {}  # groups to group data map
+
+        # contacts in roster (bare jids)
+        self._roster = set()
+
+        # selected entities, full jid
+        self._selected = set()
+
+        # options
+        self.show_disconnected = False
+        self._show_empty_groups = True
+        self.show_resources = False
+        self.show_status = False
+        # do we show entities with notifications?
+        # if True, entities will be show even if they normally would not
+        # (e.g. not in contact list) if they have notifications attached
+        self.show_entities_with_notifs = True
+
+        self.host.bridge.param_get_a_async(
+            C.SHOW_EMPTY_GROUPS,
+            "General",
+            profile_key=profile,
+            callback=self._show_empty_groups_cb,
+        )
+
+        self.host.bridge.param_get_a_async(
+            C.SHOW_OFFLINE_CONTACTS,
+            "General",
+            profile_key=profile,
+            callback=self._show_offline_contacts,
+        )
+
+        self.host.addListener("presence", self.on_presence_update, [self.profile])
+        self.host.addListener("nicknames", self.on_nicknames_update, [self.profile])
+        self.host.addListener("notification", self.on_notification, [self.profile])
+        # on_notification only updates the entity, so we can re-use it
+        self.host.addListener("notificationsClear", self.on_notification, [self.profile])
+
+    @property
+    def whoami(self):
+        return self.host.profiles[self.profile].whoami
+
+    def _show_empty_groups_cb(self, show_str):
+        # Called only by __init__
+        # self.update is not wanted here, as it is done by
+        # handler when all profiles are ready
+        self.show_empty_groups(C.bool(show_str))
+
+    def _show_offline_contacts(self, show_str):
+        # same comments as for _show_empty_groups
+        self.show_offline_contacts(C.bool(show_str))
+
+    def __contains__(self, entity):
+        """Check if entity is in contact list
+
+        An entity can be in contact list even if not in roster
+        use is_in_roster to check if entity is in roster.
+        @param entity (jid.JID): jid of the entity (resource is not ignored,
+            use bare jid if needed)
+        """
+        if entity.resource:
+            try:
+                return entity.resource in self.getCache(entity.bare, C.CONTACT_RESOURCES)
+            except exceptions.NotFound:
+                return False
+        return entity in self._cache
+
+    @property
+    def roster(self):
+        """Return all the bare JIDs of the roster entities.
+
+        @return (set[jid.JID])
+        """
+        return self._roster
+
+    @property
+    def roster_connected(self):
+        """Return all the bare JIDs of the roster entities that are connected.
+
+        @return (set[jid.JID])
+        """
+        return set(
+            [
+                entity
+                for entity in self._roster
+                if self.getCache(entity, C.PRESENCE_SHOW, default=None) is not None
+            ]
+        )
+
+    @property
+    def roster_entities_by_group(self):
+        """Return a dictionary binding the roster groups to their entities bare JIDs.
+
+        This also includes the empty group (None key).
+        @return (dict[unicode,set(jid.JID)])
+        """
+        return {group: self._groups[group]["jids"] for group in self._groups}
+
+    @property
+    def roster_groups_by_entities(self):
+        """Return a dictionary binding the entities bare JIDs to their roster groups
+
+        @return (dict[jid.JID, set(unicode)])
+        """
+        result = {}
+        for group, data in self._groups.items():
+            for entity in data["jids"]:
+                result.setdefault(entity, set()).add(group)
+        return result
+
+    @property
+    def selected(self):
+        """Return contacts currently selected
+
+        @return (set): set of selected entities
+        """
+        return self._selected
+
+    @property
+    def all_iter(self):
+        """return all know entities in cache as an iterator of tuples
+
+        entities are not sorted
+        """
+        return iter(self._cache.items())
+
+    @property
+    def items(self):
+        """Return item representation for all visible entities in cache
+
+        entities are not sorted
+        key: bare jid, value: data
+        """
+        return {
+            jid_: cache
+            for jid_, cache in self._cache.items()
+            if self.entity_visible(jid_)
+        }
+
+    def get_item(self, entity):
+        """Return item representation of requested entity
+
+        @param entity(jid.JID): bare jid of entity
+        @raise (KeyError): entity is unknown
+        """
+        return self._cache[entity]
+
+    def _got_contacts(self, contacts):
+        """Add contacts and notice parent that contacts are filled
+
+        Called during initial contact list filling
+        @param contacts(tuple): all contacts
+        """
+        for contact in contacts:
+            entity = jid.JID(contact[0])
+            if entity.resource:
+                # we use entity's bare jid to cache data, so a resource here
+                # will cause troubles
+                log.warning(
+                    "Roster entities with resources are not managed, ignoring {entity}"
+                    .format(entity=entity))
+                continue
+            self.host.contact_new_handler(*contact, profile=self.profile)
+        handler._contacts_filled(self.profile)
+
+    def _fill(self):
+        """Get all contacts from backend
+
+        Contacts will be cleared before refilling them
+        """
+        self.clear_contacts(keep_cache=True)
+        self.host.bridge.contacts_get(self.profile, callback=self._got_contacts)
+
+    def fill(self):
+        handler.fill(self.profile)
+
+    def getCache(
+        self, entity, name=None, bare_default=True, create_if_not_found=False,
+        default=Exception):
+        """Return a cache value for a contact
+
+        @param entity(jid.JID): entity of the contact from who we want data
+            (resource is used if given)
+            if a resource specific information is requested:
+                - if no resource is given (bare jid), the main resource is used,
+                    according to priority
+                - if resource is given, it is used
+        @param name(unicode): name the data to get, or None to get everything
+        @param bare_default(bool, None): if True and entity is a full jid,
+            the value of bare jid will be returned if not value is found for
+            the requested resource.
+            If False, None is returned if no value is found for the requested resource.
+            If None, bare_default will be set to False if entity is in a room, True else
+        @param create_if_not_found(bool): if True, create contact if it's not found
+            in cache
+        @param default(object): value to return when name is not found in cache
+            if Exception is used, a KeyError will be returned
+            otherwise, the given value will be used
+        @return: full cache if no name is given, or value of "name", or None
+        @raise NotFound: entity not found in cache
+        @raise KeyError: name not found in cache
+        """
+        # FIXME: resource handling need to be reworked
+        # FIXME: bare_default work for requesting full jid to get bare jid,
+        #        but not the other way
+        #        e.g.: if we have set an avatar for user@server.tld/resource
+        #        and we request user@server.tld
+        #        we won't get the avatar set in the resource
+        try:
+            cache = self._cache[entity.bare]
+        except KeyError:
+            if create_if_not_found:
+                self.set_contact(entity)
+                cache = self._cache[entity.bare]
+            else:
+                raise exceptions.NotFound
+
+        if name is None:
+            if default is not Exception:
+                raise exceptions.InternalError(
+                    "default value can only Exception when name is not specified"
+                )
+            # full cache is requested
+            return cache
+
+        if name in ("status", C.PRESENCE_STATUSES, C.PRESENCE_PRIORITY, C.PRESENCE_SHOW):
+            # these data are related to the resource
+            if not entity.resource:
+                main_resource = cache[C.CONTACT_MAIN_RESOURCE]
+                if main_resource is None:
+                    # we ignore presence info if we don't have any resource in cache
+                    # FIXME: to be checked
+                    return
+                cache = cache[C.CONTACT_RESOURCES].setdefault(main_resource, {})
+            else:
+                cache = cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {})
+
+            if name == "status":  # XXX: we get the first status for 'status' key
+                # TODO: manage main language for statuses
+                return cache[C.PRESENCE_STATUSES].get(C.PRESENCE_STATUSES_DEFAULT, "")
+
+        elif entity.resource:
+            try:
+                return cache[C.CONTACT_RESOURCES][entity.resource][name]
+            except KeyError as e:
+                if bare_default is None:
+                    bare_default = not self.is_room(entity.bare)
+                if not bare_default:
+                    if default is Exception:
+                        raise e
+                    else:
+                        return default
+
+        try:
+            return cache[name]
+        except KeyError as e:
+            if default is Exception:
+                raise e
+            else:
+                return default
+
+    def set_cache(self, entity, name, value):
+        """Set or update value for one data in cache
+
+        @param entity(JID): entity to update
+        @param name(str): value to set or update
+        """
+        self.set_contact(entity, attributes={name: value})
+
+    def get_full_jid(self, entity):
+        """Get full jid from a bare jid
+
+        @param entity(jid.JID): must be a bare jid
+        @return (jid.JID): bare jid + main resource
+        @raise ValueError: the entity is not bare
+        """
+        if entity.resource:
+            raise ValueError("get_full_jid must be used with a bare jid")
+        main_resource = self.getCache(entity, C.CONTACT_MAIN_RESOURCE)
+        return jid.JID("{}/{}".format(entity, main_resource))
+
+    def set_group_data(self, group, name, value):
+        """Register a data for a group
+
+        @param group: a valid (existing) group name
+        @param name: name of the data (can't be "jids")
+        @param value: value to set
+        """
+        assert name != "jids"
+        self._groups[group][name] = value
+
+    def get_group_data(self, group, name=None):
+        """Return value associated to group data
+
+        @param group: a valid (existing) group name
+        @param name: name of the data or None to get the whole dict
+        @return: registered value
+        """
+        if name is None:
+            return self._groups[group]
+        return self._groups[group][name]
+
+    def is_in_roster(self, entity):
+        """Tell if an entity is in roster
+
+        @param entity(jid.JID): jid of the entity
+            the bare jid will be used
+        """
+        return entity.bare in self._roster
+
+    def is_room(self, entity):
+        """Helper method to know if entity is a MUC room
+
+        @param entity(jid.JID): jid of the entity
+            hint: use bare jid here, as room can't be full jid with MUC
+        @return (bool): True if entity is a room
+        """
+        assert entity.resource is None  # FIXME: this may change when MIX will be handled
+        return self.is_special(entity, C.CONTACT_SPECIAL_GROUP)
+
+    def is_special(self, entity, special_type):
+        """Tell if an entity is of a specialy _type
+
+        @param entity(jid.JID): jid of the special entity
+            if the jid is full, will be added to special extras
+        @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP)
+        @return (bool): True if entity is from this special type
+        """
+        return self.getCache(entity, C.CONTACT_SPECIAL, default=None) == special_type
+
+    def set_special(self, entity, special_type):
+        """Set special flag on an entity
+
+        @param entity(jid.JID): jid of the special entity
+            if the jid is full, will be added to special extras
+        @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP)
+            or None to remove special flag
+        """
+        assert special_type in C.CONTACT_SPECIAL_ALLOWED + (None,)
+        self.set_cache(entity, C.CONTACT_SPECIAL, special_type)
+
+    def get_specials(self, special_type=None, bare=False):
+        """Return all the bare JIDs of the special roster entities of with given type.
+
+        @param special_type(unicode, None): if not None, filter by special type
+            (e.g. C.CONTACT_SPECIAL_GROUP)
+        @param bare(bool): return only bare jids if True
+        @return (iter[jid.JID]): found special entities
+        """
+        for entity in self._specials:
+            if bare and entity.resource:
+                continue
+            if (
+                special_type is not None
+                and self.getCache(entity, C.CONTACT_SPECIAL, default=None) != special_type
+            ):
+                continue
+            yield entity
+
+    def disconnect(self):
+        # for now we just clear contacts on disconnect
+        self.clear_contacts()
+
+    def clear_contacts(self, keep_cache=False):
+        """Clear all the contact list
+
+        @param keep_cache: if True, don't reset the cache
+        """
+        self.select(None)
+        if not keep_cache:
+            self._cache.clear()
+        self._groups.clear()
+        self._specials.clear()
+        self._roster.clear()
+        self.update()
+
+    def set_contact(self, entity, groups=None, attributes=None, in_roster=False):
+        """Add a contact to the list if it doesn't exist, else update it.
+
+        This method can be called with groups=None for the purpose of updating
+        the contact's attributes (e.g. nicknames). In that case, the groups
+        attribute must not be set to the default group but ignored. If not,
+        you may move your contact from its actual group(s) to the default one.
+
+        None value for 'groups' has a different meaning than [None]
+        which is for the default group.
+
+        @param entity (jid.JID): entity to add or replace
+            if entity is a full jid, attributes will be cached in for the full jid only
+        @param groups (list): list of groups or None to ignore the groups membership.
+        @param attributes (dict): attibutes of the added jid or to update
+        @param in_roster (bool): True if contact is from roster
+        """
+        if attributes is None:
+            attributes = {}
+
+        entity_bare = entity.bare
+        # we check if the entity is visible before changing anything
+        # this way we know if we need to do an UPDATE_ADD, UPDATE_MODIFY
+        # or an UPDATE_DELETE
+        was_visible = self.entity_visible(entity_bare)
+
+        if in_roster:
+            self._roster.add(entity_bare)
+
+        cache = self._cache.setdefault(
+            entity_bare,
+            {
+                C.CONTACT_RESOURCES: {},
+                C.CONTACT_MAIN_RESOURCE: None,
+                C.CONTACT_SELECTED: set(),
+            },
+        )
+
+        # we don't want forbidden data in attributes
+        assert not C.CONTACT_DATA_FORBIDDEN.intersection(attributes)
+
+        # we set groups and fill self._groups accordingly
+        if groups is not None:
+            if not groups:
+                groups = [None]  # [None] is the default group
+            if C.CONTACT_GROUPS in cache:
+                # XXX: don't use set(cache[C.CONTACT_GROUPS]).difference(groups) because
+                #      it won't work in Pyjamas if None is in cache[C.CONTACT_GROUPS]
+                for group in [
+                    group for group in cache[C.CONTACT_GROUPS] if group not in groups
+                ]:
+                    self._groups[group]["jids"].remove(entity_bare)
+            cache[C.CONTACT_GROUPS] = groups
+            for group in groups:
+                self._groups.setdefault(group, {}).setdefault("jids", set()).add(
+                    entity_bare
+                )
+
+        # special entities management
+        if C.CONTACT_SPECIAL in attributes:
+            if attributes[C.CONTACT_SPECIAL] is None:
+                del attributes[C.CONTACT_SPECIAL]
+                self._specials.remove(entity)
+            else:
+                self._specials.add(entity)
+                cache[C.CONTACT_MAIN_RESOURCE] = None
+                if 'nicknames' in cache:
+                    del cache['nicknames']
+
+        # now the attributes we keep in cache
+        # XXX: if entity is a full jid, we store the value for the resource only
+        cache_attr = (
+            cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {})
+            if entity.resource
+            else cache
+        )
+        for attribute, value in attributes.items():
+            if attribute == "nicknames" and self.is_special(
+                entity, C.CONTACT_SPECIAL_GROUP
+            ):
+                # we don't want to keep nicknames for MUC rooms
+                # FIXME: this is here as plugin XEP-0054 can link resource's nick
+                #        with bare jid which in the case of MUC
+                #        set the nick for the whole MUC
+                #        resulting in bad name displayed in some frontends
+                # FIXME: with plugin XEP-0054 + plugin identity refactoring, this
+                #        may not be needed anymore…
+                continue
+            cache_attr[attribute] = value
+
+        # we can update the display if needed
+        if self.entity_visible(entity_bare):
+            # if the contact was not visible, we need to add a widget
+            # else we just update id
+            update_type = C.UPDATE_MODIFY if was_visible else C.UPDATE_ADD
+            self.update([entity], update_type, self.profile)
+        elif was_visible:
+            # the entity was visible and is not anymore, we remove it
+            self.update([entity], C.UPDATE_DELETE, self.profile)
+
+    def entity_visible(self, entity, check_resource=False):
+        """Tell if the contact should be showed or hidden.
+
+        @param entity (jid.JID): jid of the contact
+        @param check_resource (bool): True if resource must be significant
+        @return (bool): True if that contact should be showed in the list
+        """
+        try:
+            show = self.getCache(entity, C.PRESENCE_SHOW)
+        except (exceptions.NotFound, KeyError):
+            return False
+
+        if check_resource:
+            selected = self._selected
+        else:
+            selected = {selected.bare for selected in self._selected}
+        return (
+            (show is not None and show != C.PRESENCE_UNAVAILABLE)
+            or self.show_disconnected
+            or entity in selected
+            or (
+                self.show_entities_with_notifs
+                and next(self.host.get_notifs(entity.bare, profile=self.profile), None)
+            )
+            or entity.resource is None and self.is_room(entity.bare)
+        )
+
+    def any_entity_visible(self, entities, check_resources=False):
+        """Tell if in a list of entities, at least one should be shown
+
+        @param entities (list[jid.JID]): list of jids
+        @param check_resources (bool): True if resources must be significant
+        @return (bool): True if a least one entity need to be shown
+        """
+        # FIXME: looks inefficient, really needed?
+        for entity in entities:
+            if self.entity_visible(entity, check_resources):
+                return True
+        return False
+
+    def is_entity_in_group(self, entity, group):
+        """Tell if an entity is in a roster group
+
+        @param entity(jid.JID): jid of the entity
+        @param group(unicode): group to check
+        @return (bool): True if the entity is in the group
+        """
+        return entity in self.get_group_data(group, "jids")
+
+    def remove_contact(self, entity):
+        """remove a contact from the list
+
+        @param entity(jid.JID): jid of the entity to remove (bare jid is used)
+        """
+        entity_bare = entity.bare
+        was_visible = self.entity_visible(entity_bare)
+        try:
+            groups = self._cache[entity_bare].get(C.CONTACT_GROUPS, set())
+        except KeyError:
+            log.error(_("Trying to delete an unknow entity [{}]").format(entity))
+        try:
+            self._roster.remove(entity_bare)
+        except KeyError:
+            pass
+        del self._cache[entity_bare]
+        for group in groups:
+            self._groups[group]["jids"].remove(entity_bare)
+            if not self._groups[group]["jids"]:
+                # FIXME: we use pop because of pyjamas:
+                #        http://wiki.goffi.org/wiki/Issues_with_Pyjamas/en
+                self._groups.pop(group)
+        for iterable in (self._selected, self._specials):
+            to_remove = set()
+            for set_entity in iterable:
+                if set_entity.bare == entity.bare:
+                    to_remove.add(set_entity)
+            iterable.difference_update(to_remove)
+        if was_visible:
+            self.update([entity], C.UPDATE_DELETE, self.profile)
+
+    def on_presence_update(self, entity, show, priority, statuses, profile):
+        """Update entity's presence status
+
+        @param entity(jid.JID): entity updated
+        @param show: availability
+        @parap priority: resource's priority
+        @param statuses: dict of statuses
+        @param profile: %(doc_profile)s
+        """
+        # FIXME: cache modification should be done with set_contact
+        #        the resources/presence handling logic should be moved there
+        was_visible = self.entity_visible(entity.bare)
+        cache = self.getCache(entity, create_if_not_found=True)
+        if show == C.PRESENCE_UNAVAILABLE:
+            if not entity.resource:
+                cache[C.CONTACT_RESOURCES].clear()
+                cache[C.CONTACT_MAIN_RESOURCE] = None
+            else:
+                try:
+                    del cache[C.CONTACT_RESOURCES][entity.resource]
+                except KeyError:
+                    log.error(
+                        "Presence unavailable received "
+                        "for an unknown resource [{}]".format(entity)
+                    )
+                if not cache[C.CONTACT_RESOURCES]:
+                    cache[C.CONTACT_MAIN_RESOURCE] = None
+        else:
+            if not entity.resource:
+                log.warning(
+                    _(
+                        "received presence from entity "
+                        "without resource: {}".format(entity)
+                    )
+                )
+            resources_data = cache[C.CONTACT_RESOURCES]
+            resource_data = resources_data.setdefault(entity.resource, {})
+            resource_data[C.PRESENCE_SHOW] = show
+            resource_data[C.PRESENCE_PRIORITY] = int(priority)
+            resource_data[C.PRESENCE_STATUSES] = statuses
+
+            if entity.bare not in self._specials:
+                # we may have resources with no priority
+                # (when a cached value is added for a not connected resource)
+                priority_resource = max(
+                    resources_data,
+                    key=lambda res: resources_data[res].get(
+                        C.PRESENCE_PRIORITY, -2 ** 32
+                    ),
+                )
+                cache[C.CONTACT_MAIN_RESOURCE] = priority_resource
+        if self.entity_visible(entity.bare):
+            update_type = C.UPDATE_MODIFY if was_visible else C.UPDATE_ADD
+            self.update([entity], update_type, self.profile)
+        elif was_visible:
+            self.update([entity], C.UPDATE_DELETE, self.profile)
+
+    def on_nicknames_update(self, entity, nicknames, profile):
+        """Update entity's nicknames
+
+        @param entity(jid.JID): entity updated
+        @param nicknames(list[unicode]): nicknames of the entity
+        @param profile: %(doc_profile)s
+        """
+        assert profile == self.profile
+        self.set_cache(entity, "nicknames", nicknames)
+
+    def on_notification(self, entity, notif, profile):
+        """Update entity with notification
+
+        @param entity(jid.JID): entity updated
+        @param notif(dict): notification data
+        @param profile: %(doc_profile)s
+        """
+        assert profile == self.profile
+        if entity is not None and self.entity_visible(entity):
+            self.update([entity], C.UPDATE_MODIFY, profile)
+
+    def unselect(self, entity):
+        """Unselect an entity
+
+         @param entity(jid.JID): entity to unselect
+        """
+        try:
+            cache = self._cache[entity.bare]
+        except:
+            log.error("Try to unselect an entity not in cache")
+        else:
+            try:
+                cache[C.CONTACT_SELECTED].remove(entity.resource)
+            except KeyError:
+                log.error("Try to unselect a not selected entity")
+            else:
+                self._selected.remove(entity)
+                self.update([entity], C.UPDATE_SELECTION)
+
+    def select(self, entity):
+        """Select an entity
+
+        @param entity(jid.JID, None): entity to select (resource is significant)
+            None to unselect all entities
+        """
+        if entity is None:
+            self._selected.clear()
+            for cache in self._cache.values():
+                cache[C.CONTACT_SELECTED].clear()
+            self.update(type_=C.UPDATE_SELECTION, profile=self.profile)
+        else:
+            log.debug("select %s" % entity)
+            try:
+                cache = self._cache[entity.bare]
+            except:
+                log.error("Try to select an entity not in cache")
+            else:
+                cache[C.CONTACT_SELECTED].add(entity.resource)
+                self._selected.add(entity)
+                self.update([entity], C.UPDATE_SELECTION, profile=self.profile)
+
+    def show_offline_contacts(self, show):
+        """Tell if offline contacts should be shown
+
+        @param show(bool): True if offline contacts should be shown
+        """
+        assert isinstance(show, bool)
+        if self.show_disconnected == show:
+            return
+        self.show_disconnected = show
+        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
+
+    def show_empty_groups(self, show):
+        assert isinstance(show, bool)
+        if self._show_empty_groups == show:
+            return
+        self._show_empty_groups = show
+        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
+
+    def show_resources(self, show):
+        assert isinstance(show, bool)
+        if self.show_resources == show:
+            return
+        self.show_resources = show
+        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
+
+    def plug(self):
+        handler.add_profile(self.profile)
+
+    def unplug(self):
+        handler.remove_profile(self.profile)
+
+    def update(self, entities=None, type_=None, profile=None):
+        handler.update(entities, type_, profile)
+
+
+class QuickContactListHandler(object):
+    def __init__(self, host):
+        super(QuickContactListHandler, self).__init__()
+        self.host = host
+        global handler
+        if handler is not None:
+            raise exceptions.InternalError(
+                "QuickContactListHandler must be instanciated only once"
+            )
+        handler = self
+        self._clist = {}  # key: profile, value: ProfileContactList
+        self._widgets = set()
+        self._update_locked = False  # se to True to ignore updates
+
+    def __getitem__(self, profile):
+        """Return ProfileContactList instance for the requested profile"""
+        return self._clist[profile]
+
+    def __contains__(self, entity):
+        """Check if entity is in contact list
+
+        @param entity (jid.JID): jid of the entity (resource is not ignored,
+            use bare jid if needed)
+        """
+        for contact_list in self._clist.values():
+            if entity in contact_list:
+                return True
+        return False
+
+    @property
+    def roster(self):
+        """Return all the bare JIDs of the roster entities.
+
+        @return (set[jid.JID])
+        """
+        entities = set()
+        for contact_list in self._clist.values():
+            entities.update(contact_list.roster)
+        return entities
+
+    @property
+    def roster_connected(self):
+        """Return all the bare JIDs of the roster entities that are connected.
+
+        @return (set[jid.JID])
+        """
+        entities = set()
+        for contact_list in self._clist.values():
+            entities.update(contact_list.roster_connected)
+        return entities
+
+    @property
+    def roster_entities_by_group(self):
+        """Return a dictionary binding the roster groups to their entities bare
+        JIDs. This also includes the empty group (None key).
+
+        @return (dict[unicode,set(jid.JID)])
+        """
+        groups = {}
+        for contact_list in self._clist.values():
+            groups.update(contact_list.roster_entities_by_group)
+        return groups
+
+    @property
+    def roster_groups_by_entities(self):
+        """Return a dictionary binding the entities bare JIDs to their roster
+        groups.
+
+        @return (dict[jid.JID, set(unicode)])
+        """
+        entities = {}
+        for contact_list in self._clist.values():
+            entities.update(contact_list.roster_groups_by_entities)
+        return entities
+
+    @property
+    def selected(self):
+        """Return contacts currently selected
+
+        @return (set): set of selected entities
+        """
+        entities = set()
+        for contact_list in self._clist.values():
+            entities.update(contact_list.selected)
+        return entities
+
+    @property
+    def all_iter(self):
+        """Return item representation for all entities in cache
+
+        items are unordered
+        """
+        for profile, contact_list in self._clist.items():
+            for bare_jid, cache in contact_list.all_iter:
+                data = cache.copy()
+                data[C.CONTACT_PROFILE] = profile
+                yield bare_jid, data
+
+    @property
+    def items(self):
+        """Return item representation for visible entities in cache
+
+        items are unordered
+        key: bare jid, value: data
+        """
+        items = {}
+        for profile, contact_list in self._clist.items():
+            for bare_jid, cache in contact_list.items.items():
+                data = cache.copy()
+                items[bare_jid] = data
+                data[C.CONTACT_PROFILE] = profile
+        return items
+
+    @property
+    def items_sorted(self):
+        """Return item representation for visible entities in cache
+
+        items are ordered using self.items_sort
+        key: bare jid, value: data
+        """
+        return self.items_sort(self.items)
+
+    def items_sort(self, items):
+        """sort items
+
+       @param items(dict): items to sort (will be emptied !)
+       @return (OrderedDict): sorted items
+       """
+        ordered_items = OrderedDict()
+        bare_jids = sorted(items.keys())
+        for jid_ in bare_jids:
+            ordered_items[jid_] = items.pop(jid_)
+        return ordered_items
+
+    def register(self, widget):
+        """Register a QuickContactList widget
+
+        This method should only be used in QuickContactList
+        """
+        self._widgets.add(widget)
+
+    def unregister(self, widget):
+        """Unregister a QuickContactList widget
+
+        This method should only be used in QuickContactList
+        """
+        self._widgets.remove(widget)
+
+    def add_profiles(self, profiles):
+        """Add a contact list for plugged profiles
+
+        @param profile(iterable[unicode]): plugged profiles
+        """
+        for profile in profiles:
+            if profile not in self._clist:
+                self._clist[profile] = ProfileContactList(profile)
+        return [self._clist[profile] for profile in profiles]
+
+    def add_profile(self, profile):
+        return self.add_profiles([profile])[0]
+
+    def remove_profiles(self, profiles):
+        """Remove given unplugged profiles from contact list
+
+        @param profile(iterable[unicode]): unplugged profiles
+        """
+        for profile in profiles:
+            del self._clist[profile]
+
+    def remove_profile(self, profile):
+        self.remove_profiles([profile])
+
+    def get_special_extras(self, special_type=None):
+        """Return special extras with given type
+
+        If special_type is None, return all special extras.
+
+        @param special_type(unicode, None): one of special type
+            (e.g. C.CONTACT_SPECIAL_GROUP)
+            None to return all special extras.
+        @return (set[jid.JID])
+        """
+        entities = set()
+        for contact_list in self._clist.values():
+            entities.update(contact_list.get_special_extras(special_type))
+        return entities
+
+    def _contacts_filled(self, profile):
+        self._to_fill.remove(profile)
+        if not self._to_fill:
+            del self._to_fill
+            # we need a full update when all contacts are filled
+            self.update()
+        self.host.call_listeners("contactsFilled", profile=profile)
+
+    def fill(self, profile=None):
+        """Get all contacts from backend, and fill the widget
+
+        Contacts will be cleared before refilling them
+        @param profile(unicode, None): profile to fill
+            None to fill all profiles
+        """
+        try:
+            to_fill = self._to_fill
+        except AttributeError:
+            to_fill = self._to_fill = set()
+
+        # we check if profiles have already been filled
+        # to void filling them several times
+        filled = to_fill.copy()
+
+        if profile is not None:
+            assert profile in self._clist
+            to_fill.add(profile)
+        else:
+            to_fill.update(list(self._clist.keys()))
+
+        remaining = to_fill.difference(filled)
+        if remaining != to_fill:
+            log.debug(
+                "Not re-filling already filled contact list(s) for {}".format(
+                    ", ".join(to_fill.intersection(filled))
+                )
+            )
+        for profile in remaining:
+            self._clist[profile]._fill()
+
+    def clear_contacts(self, keep_cache=False):
+        """Clear all the contact list
+
+        @param keep_cache: if True, don't reset the cache
+        """
+        for contact_list in self._clist.values():
+            contact_list.clear_contacts(keep_cache)
+        # we need a full update
+        self.update()
+
+    def select(self, entity):
+        for contact_list in self._clist.values():
+            contact_list.select(entity)
+
+    def unselect(self, entity):
+        for contact_list in self._clist.values():
+            contact_list.select(entity)
+
+    def lock_update(self, locked=True, do_update=True):
+        """Forbid contact list updates
+
+        Used mainly while profiles are plugged, as many updates can occurs, causing
+        an impact on performances
+        @param locked(bool): updates are forbidden if True
+        @param do_update(bool): if True, a full update is done after unlocking
+            if set to False, widget state can be inconsistent, be sure to know
+            what youa re doing!
+        """
+        log.debug(
+            "Contact lists updates are now {}".format(
+                "LOCKED" if locked else "UNLOCKED"
+            )
+        )
+        self._update_locked = locked
+        if not locked and do_update:
+            self.update()
+
+    def update(self, entities=None, type_=None, profile=None):
+        if not self._update_locked:
+            for widget in self._widgets:
+                widget.update(entities, type_, profile)
+
+
+class QuickContactList(QuickWidget):
+    """This class manage the visual representation of contacts"""
+
+    SINGLE = False
+    PROFILES_MULTIPLE = True
+    # Can be linked to no profile (e.g. at the early frontend start)
+    PROFILES_ALLOW_NONE = True
+
+    def __init__(self, host, profiles):
+        super(QuickContactList, self).__init__(host, None, profiles)
+
+        # options
+        # for next values, None means use indivual value per profile
+        # True or False mean override these values for all profiles
+        self.show_disconnected = None  # TODO
+        self._show_empty_groups = None  # TODO
+        self.show_resources = None  # TODO
+        self.show_status = None  # TODO
+
+    def post_init(self):
+        """Method to be called by frontend after widget is initialised"""
+        handler.register(self)
+
+    @property
+    def all_iter(self):
+        return handler.all_iter
+
+    @property
+    def items(self):
+        return handler.items
+
+    @property
+    def items_sorted(self):
+        return handler.items_sorted
+
+    def update(self, entities=None, type_=None, profile=None):
+        """Update the display when something changed
+
+        @param entities(iterable[jid.JID], None): updated entities,
+            None to update the whole contact list
+        @param type_(unicode, None): update type, may be:
+            - C.UPDATE_DELETE: entity deleted
+            - C.UPDATE_MODIFY: entity updated
+            - C.UPDATE_ADD: entity added
+            - C.UPDATE_SELECTION: selection modified
+            - C.UPDATE_STRUCTURE: organisation of items is modified (not items
+              themselves)
+            or None for undefined update
+            Note that events correspond to addition, modification and deletion
+            of items on the whole contact list. If the contact is visible or not
+            has no influence on the type_.
+        @param profile(unicode, None): profile concerned with the update
+            None if all profiles need to be updated
+        """
+        raise NotImplementedError
+
+    def on_delete(self):
+        QuickWidget.on_delete(self)
+        handler.unregister(self)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_contact_management.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+
+
+# helper class for making a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.frontends.tools.jid import JID
+
+
+class QuickContactManagement(object):
+    """This helper class manage the contacts and ease the use of nicknames and shortcuts"""
+
+    ### FIXME: is SàT a better place for all this stuff ??? ###
+
+    def __init__(self):
+        self.__contactlist = {}
+
+    def __contains__(self, entity):
+        return entity.bare in self.__contactlist
+
+    def clear(self):
+        """Clear all the contact list"""
+        self.__contactlist.clear()
+
+    def add(self, entity):
+        """Add contact to the list, update resources"""
+        if entity.bare not in self.__contactlist:
+            self.__contactlist[entity.bare] = {"resources": []}
+        if not entity.resource:
+            return
+        if entity.resource in self.__contactlist[entity.bare]["resources"]:
+            self.__contactlist[entity.bare]["resources"].remove(entity.resource)
+        self.__contactlist[entity.bare]["resources"].append(entity.resource)
+
+    def get_cont_from_group(self, group):
+        """Return all contacts which are in given group"""
+        result = []
+        for contact in self.__contactlist:
+            if "groups" in self.__contactlist[contact]:
+                if group in self.__contactlist[contact]["groups"]:
+                    result.append(JID(contact))
+        return result
+
+    def get_attr(self, entity, name):
+        """Return a specific attribute of contact, or all attributes
+        @param entity: jid of the contact
+        @param name: name of the attribute
+        @return: asked attribute"""
+        if entity.bare in self.__contactlist:
+            if name == "status":  # FIXME: for the moment, we only use the first status
+                if self.__contactlist[entity.bare]["statuses"]:
+                    return list(self.__contactlist[entity.bare]["statuses"].values())[0]
+            if name in self.__contactlist[entity.bare]:
+                return self.__contactlist[entity.bare][name]
+        else:
+            log.debug(_("Trying to get attribute for an unknown contact"))
+        return None
+
+    def is_connected(self, entity):
+        """Tell if the contact is online"""
+        return entity.bare in self.__contactlist
+
+    def remove(self, entity):
+        """remove resource. If no more resource is online or is no resource is specified, contact is deleted"""
+        try:
+            if entity.resource:
+                self.__contactlist[entity.bare]["resources"].remove(entity.resource)
+            if not entity.resource or not self.__contactlist[entity.bare]["resources"]:
+                # no more resource available: the contact seems really disconnected
+                del self.__contactlist[entity.bare]
+        except KeyError:
+            log.error(_("INTERNAL ERROR: Key log.error"))
+            raise
+
+    def update(self, entity, key, value):
+        """Update attribute of contact
+        @param entity: jid of the contact
+        @param key: name of the attribute
+        @param value: value of the attribute
+        """
+        if entity.bare in self.__contactlist:
+            self.__contactlist[entity.bare][key] = value
+        else:
+            log.debug(_("Trying to update an unknown contact: %s") % entity.bare)
+
+    def get_full(self, entity):
+        return entity.bare + "/" + self.__contactlist[entity.bare]["resources"][-1]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_game_tarot.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+
+
+# helper class for making a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.frontends.tools.jid import JID
+
+
+class QuickTarotGame(object):
+    def __init__(self, parent, referee, players):
+        self._autoplay = None  # XXX: use 0 to activate fake play, None else
+        self.parent = parent
+        self.referee = referee
+        self.players = players
+        self.played = {}
+        for player in players:
+            self.played[player] = None
+        self.player_nick = parent.nick
+        self.bottom_nick = str(self.player_nick)
+        idx = self.players.index(self.player_nick)
+        idx = (idx + 1) % len(self.players)
+        self.right_nick = str(self.players[idx])
+        idx = (idx + 1) % len(self.players)
+        self.top_nick = str(self.players[idx])
+        idx = (idx + 1) % len(self.players)
+        self.left_nick = str(self.players[idx])
+        self.bottom_nick = str(self.player_nick)
+        self.selected = []  # Card choosed by the player (e.g. during ecart)
+        self.hand_size = 13  # number of cards in a hand
+        self.hand = []
+        self.to_show = []
+        self.state = None
+
+    def reset_round(self):
+        """Reset the game's variables to be reatty to start the next round"""
+        del self.selected[:]
+        del self.hand[:]
+        del self.to_show[:]
+        self.state = None
+        for pl in self.played:
+            self.played[pl] = None
+
+    def get_player_location(self, nick):
+        """return player location (top,bottom,left or right)"""
+        for location in ["top", "left", "bottom", "right"]:
+            if getattr(self, "%s_nick" % location) == nick:
+                return location
+        assert False
+
+    def load_cards(self):
+        """Load all the cards in memory
+        @param dir: directory where the PNG files are"""
+        self.cards = {}
+        self.deck = []
+        self.cards[
+            "atout"
+        ] = {}  # As Tarot is a french game, it's more handy & logical to keep french names
+        self.cards["pique"] = {}  # spade
+        self.cards["coeur"] = {}  # heart
+        self.cards["carreau"] = {}  # diamond
+        self.cards["trefle"] = {}  # club
+
+    def tarot_game_new_handler(self, hand):
+        """Start a new game, with given hand"""
+        assert len(self.hand) == 0
+        for suit, value in hand:
+            self.hand.append(self.cards[suit, value])
+        self.hand.sort()
+        self.state = "init"
+
+    def tarot_game_choose_contrat_handler(self, xml_data):
+        """Called when the player as to select his contrat
+        @param xml_data: SàT xml representation of the form"""
+        raise NotImplementedError
+
+    def tarot_game_show_cards_handler(self, game_stage, cards, data):
+        """Display cards in the middle of the game (to show for e.g. chien ou poignée)"""
+        self.to_show = []
+        for suit, value in cards:
+            self.to_show.append(self.cards[suit, value])
+        if game_stage == "chien" and data["attaquant"] == self.player_nick:
+            self.state = "wait_for_ecart"
+        else:
+            self.state = "chien"
+
+    def tarot_game_your_turn_handler(self):
+        """Called when we have to play :)"""
+        if self.state == "chien":
+            self.to_show = []
+        self.state = "play"
+        self.__fake_play()
+
+    def __fake_play(self):
+        """Convenience method for stupid autoplay
+        /!\ don't forgot to comment any interactive dialog for invalid card"""
+        if self._autoplay == None:
+            return
+        if self._autoplay >= len(self.hand):
+            self._autoplay = 0
+        card = self.hand[self._autoplay]
+        self.parent.host.bridge.tarot_game_play_cards(
+            self.player_nick, self.referee, [(card.suit, card.value)], self.parent.profile
+        )
+        del self.hand[self._autoplay]
+        self.state = "wait"
+        self._autoplay += 1
+
+    def tarot_game_score_handler(self, xml_data, winners, loosers):
+        """Called at the end of a game
+        @param xml_data: SàT xml representation of the scores
+        @param winners: list of winners' nicks
+        @param loosers: list of loosers' nicks"""
+        raise NotImplementedError
+
+    def tarot_game_cards_played_handler(self, player, cards):
+        """A card has been played by player"""
+        if self.to_show:
+            self.to_show = []
+        pl_cards = []
+        if self.played[player] != None:  # FIXME
+            for pl in self.played:
+                self.played[pl] = None
+        for suit, value in cards:
+            pl_cards.append(self.cards[suit, value])
+        self.played[player] = pl_cards[0]
+
+    def tarot_game_invalid_cards_handler(self, phase, played_cards, invalid_cards):
+        """Invalid cards have been played
+        @param phase: phase of the game
+        @param played_cards: all the cards played
+        @param invalid_cards: cards which are invalid"""
+
+        if phase == "play":
+            self.state = "play"
+        elif phase == "ecart":
+            self.state = "ecart"
+        else:
+            log.error("INTERNAL ERROR: unmanaged game phase")
+
+        for suit, value in played_cards:
+            self.hand.append(self.cards[suit, value])
+
+        self.hand.sort()
+        self.__fake_play()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_games.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+
+
+# helper class for making a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+from libervia.backend.core.i18n import _
+
+from libervia.frontends.tools import jid
+from libervia.frontends.tools import games
+from libervia.frontends.quick_frontend.constants import Const as C
+
+from . import quick_chat
+
+
+class RoomGame(object):
+    _game_name = None
+    _signal_prefix = None
+    _signal_suffixes = None
+
+    @classmethod
+    def register_signals(cls, host):
+        def make_handler(suffix, signal):
+            def handler(*args):
+                if suffix in ("Started", "Players"):
+                    return cls.started_handler(host, suffix, *args)
+                return cls.generic_handler(host, signal, *args)
+
+            return handler
+
+        for suffix in cls._signal_suffixes:
+            signal = cls._signal_prefix + suffix
+            host.register_signal(
+                signal, handler=make_handler(suffix, signal), iface="plugin"
+            )
+
+    @classmethod
+    def started_handler(cls, host, suffix, *args):
+        room_jid, args, profile = jid.JID(args[0]), args[1:-1], args[-1]
+        referee, players, args = args[0], args[1], args[2:]
+        chat_widget = host.widgets.get_or_create_widget(
+            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
+        )
+
+        # update symbols
+        if cls._game_name not in chat_widget.visible_states:
+            chat_widget.visible_states.append(cls._game_name)
+        symbols = games.SYMBOLS[cls._game_name]
+        index = 0
+        contact_list = host.contact_lists[profile]
+        for occupant in chat_widget.occupants:
+            occupant_jid = jid.new_resource(room_jid, occupant)
+            contact_list.set_cache(
+                occupant_jid,
+                cls._game_name,
+                symbols[index % len(symbols)] if occupant in players else None,
+            )
+            chat_widget.update(occupant_jid)
+
+        if suffix == "Players" or chat_widget.nick not in players:
+            return  # waiting for other players to join, or not playing
+        if cls._game_name in chat_widget.games:
+            return  # game panel is already there
+        real_class = host.widgets.get_real_class(cls)
+        if real_class == cls:
+            host.show_dialog(
+                _(
+                    "A {game} activity between {players} has been started, but you couldn't take part because your client doesn't support it."
+                ).format(game=cls._game_name, players=", ".join(players)),
+                _("{game} Game").format(game=cls._game_name),
+            )
+            return
+        panel = real_class(chat_widget, referee, players, *args)
+        chat_widget.games[cls._game_name] = panel
+        chat_widget.add_game_panel(panel)
+
+    @classmethod
+    def generic_handler(cls, host, signal, *args):
+        room_jid, args, profile = jid.JID(args[0]), args[1:-1], args[-1]
+        chat_widget = host.widgets.get_widget(quick_chat.QuickChat, room_jid, profile)
+        if chat_widget:
+            try:
+                game_panel = chat_widget.games[cls._game_name]
+            except KeyError:
+                log.error(
+                    "TODO: better game synchronisation - received signal %s but no panel is found"
+                    % signal
+                )
+                return
+            else:
+                getattr(game_panel, "%sHandler" % signal)(*args)
+
+
+class Tarot(RoomGame):
+    _game_name = "Tarot"
+    _signal_prefix = "tarotGame"
+    _signal_suffixes = (
+        "Started",
+        "Players",
+        "New",
+        "ChooseContrat",
+        "ShowCards",
+        "YourTurn",
+        "Score",
+        "CardsPlayed",
+        "InvalidCards",
+    )
+
+
+class Quiz(RoomGame):
+    _game_name = "Quiz"
+    _signal_prefix = "quizGame"
+    _signal_suffixes = (
+        "Started",
+        "New",
+        "Question",
+        "PlayerBuzzed",
+        "PlayerSays",
+        "AnswerResult",
+        "TimerExpired",
+        "TimerRestarted",
+    )
+
+
+class Radiocol(RoomGame):
+    _game_name = "Radiocol"
+    _signal_prefix = "radiocol"
+    _signal_suffixes = (
+        "Started",
+        "Players",
+        "SongRejected",
+        "Preload",
+        "Play",
+        "NoUpload",
+        "UploadOk",
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_list_manager.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2013-2016 Adrien Cossa <souliane@mailoo.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/>.
+
+
+class QuickTagList(object):
+    """This class manages a sorted list of tagged items, and a complementary sorted list of suggested but non tagged items."""
+
+    def __init__(self, items=None):
+        """
+
+        @param items (list): the suggested list of non tagged items
+        """
+        self.tagged = []
+        self.original = (
+            items[:] if items else []
+        )  # XXX: copy the list! It will be modified
+        self.untagged = (
+            items[:] if items else []
+        )  # XXX: copy the list! It will be modified
+        self.untagged.sort()
+
+    @property
+    def items(self):
+        """Return a sorted list of all items, tagged or untagged.
+        
+        @return list
+        """
+        res = list(set(self.tagged).union(self.untagged))
+        res.sort()
+        return res
+
+    def tag(self, items):
+        """Tag some items.
+
+        @param items (list): items to be tagged
+        """
+        for item in items:
+            if item not in self.tagged:
+                self.tagged.append(item)
+            if item in self.untagged:
+                self.untagged.remove(item)
+        self.tagged.sort()
+        self.untagged.sort()
+
+    def untag(self, items):
+        """Untag some items.
+  
+        @param items (list): items to be untagged
+        """
+        for item in items:
+            if item not in self.untagged and item in self.original:
+                self.untagged.append(item)
+            if item in self.tagged:
+                self.tagged.remove(item)
+        self.tagged.sort()
+        self.untagged.sort()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_menus.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,491 @@
+#!/usr/bin/env python3
+
+
+# helper class for making a SAT frontend
+# Copyright (C) 2009-2021 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/>.
+
+try:
+    # FIXME: to be removed when an acceptable solution is here
+    str("")  # XXX: unicode doesn't exist in pyjamas
+except (
+    TypeError,
+    AttributeError,
+):  # Error raised is not the same depending on pyjsbuild options
+    str = str
+
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _, language_switch
+
+log = getLogger(__name__)
+from libervia.frontends.quick_frontend.constants import Const as C
+from collections import OrderedDict
+
+
+## items ##
+
+
+class MenuBase(object):
+    ACTIVE = True
+
+    def __init__(self, name, extra=None):
+        """
+        @param name(unicode): canonical name of the item
+        @param extra(dict[unicode, unicode], None): same as in [add_menus]
+        """
+        self._name = name
+        self.set_extra(extra)
+
+    @property
+    def canonical(self):
+        """Return the canonical name of the container, used to identify it"""
+        return self._name
+
+    @property
+    def name(self):
+        """Return the name of the container, can be translated"""
+        return self._name
+
+    def set_extra(self, extra):
+        if extra is None:
+            extra = {}
+        self.icon = extra.get("icon")
+
+
+class MenuItem(MenuBase):
+    """A callable item in the menu"""
+
+    CALLABLE = False
+
+    def __init__(self, name, name_i18n, extra=None, type_=None):
+        """
+        @param name(unicode): canonical name of the item
+        @param name_i18n(unicode): translated name of the item
+        @param extra(dict[unicode, unicode], None): same as in [add_menus]
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        """
+        MenuBase.__init__(self, name, extra)
+        self._name_i18n = name_i18n if name_i18n else name
+        self.type = type_
+
+    @property
+    def name(self):
+        return self._name_i18n
+
+    def collect_data(self, caller):
+        """Get data according to data_collector
+
+        @param caller: Menu caller
+        """
+        assert self.type is not None  # if data collector are used, type must be set
+        data_collector = QuickMenusManager.get_data_collector(self.type)
+
+        if data_collector is None:
+            return {}
+
+        elif callable(data_collector):
+            return data_collector(caller, self.name)
+
+        else:
+            if caller is None:
+                log.error("Caller can't be None with a dictionary as data_collector")
+                return {}
+            data = {}
+            for data_key, caller_attr in data_collector.items():
+                data[data_key] = str(getattr(caller, caller_attr))
+            return data
+
+    def call(self, caller, profile=C.PROF_KEY_NONE):
+        """Execute the menu item
+
+        @param caller: instance linked to the menu
+        @param profile: %(doc_profile)s
+        """
+        raise NotImplementedError
+
+
+class MenuItemDistant(MenuItem):
+    """A MenuItem with a distant callback"""
+
+    CALLABLE = True
+
+    def __init__(self, host, type_, name, name_i18n, id_, extra=None):
+        """
+        @param host: %(doc_host)s
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        @param name(unicode): canonical name of the item
+        @param name_i18n(unicode): translated name of the item
+        @param id_(unicode): id of the distant callback
+        @param extra(dict[unicode, unicode], None): same as in [add_menus]
+        """
+        MenuItem.__init__(self, name, name_i18n, extra, type_)
+        self.host = host
+        self.id = id_
+
+    def call(self, caller, profile=C.PROF_KEY_NONE):
+        data = self.collect_data(caller)
+        log.debug("data collected: %s" % data)
+        self.host.action_launch(self.id, data, profile=profile)
+
+
+class MenuItemLocal(MenuItem):
+    """A MenuItem with a local callback"""
+
+    CALLABLE = True
+
+    def __init__(self, type_, name, name_i18n, callback, extra=None):
+        """
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        @param name(unicode): canonical name of the item
+        @param name_i18n(unicode): translated name of the item
+        @param callback(callable): local callback.
+            Will be called with no argument if data_collector is None
+            and with caller, profile, and requested data otherwise
+        @param extra(dict[unicode, unicode], None): same as in [add_menus]
+        """
+        MenuItem.__init__(self, name, name_i18n, extra, type_)
+        self.callback = callback
+
+    def call(self, caller, profile=C.PROF_KEY_NONE):
+        data_collector = QuickMenusManager.get_data_collector(self.type)
+        if data_collector is None:
+            # FIXME: would not it be better if caller and profile where used as arguments?
+            self.callback()
+        else:
+            self.callback(caller, self.collect_data(caller), profile)
+
+
+class MenuHook(MenuItemLocal):
+    """A MenuItem which replace an expected item from backend"""
+
+    pass
+
+
+class MenuPlaceHolder(MenuItem):
+    """A non existant menu which is used to keep a position"""
+
+    ACTIVE = False
+
+    def __init__(self, name):
+        MenuItem.__init__(self, name, name)
+
+
+class MenuSeparator(MenuItem):
+    """A separation between items/categories"""
+
+    SEP_IDX = 0
+
+    def __init__(self):
+        MenuSeparator.SEP_IDX += 1
+        name = "___separator_{}".format(MenuSeparator.SEP_IDX)
+        MenuItem.__init__(self, name, name)
+
+
+## containers ##
+
+
+class MenuContainer(MenuBase):
+    def __init__(self, name, extra=None):
+        MenuBase.__init__(self, name, extra)
+        self._items = OrderedDict()
+
+    def __len__(self):
+        return len(self._items)
+
+    def __contains__(self, item):
+        return item.canonical in self._items
+
+    def __iter__(self):
+        return iter(self._items.values())
+
+    def __getitem__(self, item):
+        try:
+            return self._items[item.canonical]
+        except KeyError:
+            raise KeyError(item)
+
+    def get_or_create(self, item):
+        log.debug(
+            "MenuContainer get_or_create: item=%s name=%s\nlist=%s"
+            % (item, item.canonical, list(self._items.keys()))
+        )
+        try:
+            return self[item]
+        except KeyError:
+            self.append(item)
+            return item
+
+    def get_active_menus(self):
+        """Return an iterator on active children"""
+        for child in self._items.values():
+            if child.ACTIVE:
+                yield child
+
+    def append(self, item):
+        """add an item at the end of current ones
+
+        @param item: instance of MenuBase (must be unique in container)
+        """
+        assert isinstance(item, MenuItem) or isinstance(item, MenuContainer)
+        assert item.canonical not in self._items
+        self._items[item.canonical] = item
+
+    def replace(self, item):
+        """add an item at the end of current ones or replace an existing one"""
+        self._items[item.canonical] = item
+
+
+class MenuCategory(MenuContainer):
+    """A category which can hold other menus or categories"""
+
+    def __init__(self, name, name_i18n=None, extra=None):
+        """
+        @param name(unicode): canonical name
+        @param name_i18n(unicode, None): translated name
+        @param icon(unicode, None): same as in MenuBase.__init__
+        """
+        log.debug("creating menuCategory %s with extra %s" % (name, extra))
+        MenuContainer.__init__(self, name, extra)
+        self._name_i18n = name_i18n or name
+
+    @property
+    def name(self):
+        return self._name_i18n
+
+
+class MenuType(MenuContainer):
+    """A type which can hold other menus or categories"""
+
+    pass
+
+
+## manager ##
+
+
+class QuickMenusManager(object):
+    """Manage all the menus"""
+
+    _data_collectors = {
+        C.MENU_GLOBAL: None
+    }  # No data is associated with C.MENU_GLOBAL items
+
+    def __init__(self, host, menus=None, language=None):
+        """
+        @param host: %(doc_host)s
+        @param menus(iterable): menus as in [add_menus]
+        @param language: same as in [i18n.language_switch]
+        """
+        self.host = host
+        MenuBase.host = host
+        self.language = language
+        self.menus = {}
+        if menus is not None:
+            self.add_menus(menus)
+
+    def _get_path_i_1_8_n(self, path):
+        """Return translated version of path"""
+        language_switch(self.language)
+        path_i18n = [_(elt) for elt in path]
+        language_switch()
+        return path_i18n
+
+    def _create_categories(self, type_, path, path_i18n=None, top_extra=None):
+        """Create catogories of the path
+
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
+        @param path_i18n(list[unicode], None):  translated menu path (same lenght as path) or None to get deferred translation of path
+        @param top_extra: extra data to use on the first element of path only. If the first element already exists and is reused, top_extra will be ignored (you'll have to manually change it if you really want to).
+        @return (MenuContainer): last category created, or MenuType if path is empty
+        """
+        if path_i18n is None:
+            path_i18n = self._get_path_i_1_8_n(path)
+        assert len(path) == len(path_i18n)
+        menu_container = self.menus.setdefault(type_, MenuType(type_))
+
+        for idx, category in enumerate(path):
+            menu_category = MenuCategory(category, path_i18n[idx], extra=top_extra)
+            menu_container = menu_container.get_or_create(menu_category)
+            top_extra = None
+
+        return menu_container
+
+    @staticmethod
+    def add_data_collector(type_, data_collector):
+        """Associate a data collector to a menu type
+
+        A data collector is a method or a map which allow to collect context data to construct the dictionnary which will be sent to the bridge method managing the menu item.
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        @param data_collector(dict[unicode,unicode], callable, None): can be:
+            - a dict which map data name to local name.
+                The attribute named after the dict values will be getted from caller, and put in data.
+                e.g.: if data_collector={'room_jid':'target'}, then the "room_jid" data will be the value of the "target" attribute of the caller.
+            - a callable which must return the data dictionnary. callable will have caller and item name as argument
+            - None: an empty dict will be used
+        """
+        QuickMenusManager._data_collectors[type_] = data_collector
+
+    @staticmethod
+    def get_data_collector(type_):
+        """Get data_collector associated to type_
+
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        @return (callable, dict, None): data_collector
+        """
+        try:
+            return QuickMenusManager._data_collectors[type_]
+        except KeyError:
+            log.error("No data collector registered for {}".format(type_))
+            return None
+
+    def add_menu_item(self, type_, path, item, path_i18n=None, top_extra=None):
+        """Add a MenuItemBase instance
+
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu], stop at the last parent category
+        @param item(MenuItem): a instancied item
+        @param path_i18n(list[unicode],None):  translated menu path (same lenght as path) or  None to use deferred translation of path
+        @param top_extra: same as in [_create_categories]
+        """
+        if path_i18n is None:
+            path_i18n = self._get_path_i_1_8_n(path)
+        assert path and len(path) == len(path_i18n)
+
+        menu_container = self._create_categories(type_, path, path_i18n, top_extra)
+
+        if item in menu_container:
+            if isinstance(item, MenuHook):
+                menu_container.replace(item)
+            else:
+                container_item = menu_container[item]
+                if isinstance(container_item, MenuPlaceHolder):
+                    menu_container.replace(item)
+                elif isinstance(container_item, MenuHook):
+                    # MenuHook must not be replaced
+                    log.debug(
+                        "ignoring menu at path [{}] because a hook is already in place".format(
+                            path
+                        )
+                    )
+                else:
+                    log.error("Conflicting menus at path [{}]".format(path))
+        else:
+            log.debug("Adding menu [{type_}] {path}".format(type_=type_, path=path))
+            menu_container.append(item)
+            self.host.call_listeners("menu", type_, path, path_i18n, item)
+
+    def add_menu(
+        self,
+        type_,
+        path,
+        path_i18n=None,
+        extra=None,
+        top_extra=None,
+        id_=None,
+        callback=None,
+    ):
+        """Add a menu item
+
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
+        @param path_i18n(list[unicode], None):  translated menu path (same lenght as path), or None to get deferred translation
+        @param extra(dict[unicode, unicode], None): same as in [add_menus]
+        @param top_extra: same as in [_create_categories]
+        @param id_(unicode): callback id (mutually exclusive with callback)
+        @param callback(callable): local callback (mutually exclusive with id_)
+        """
+        if path_i18n is None:
+            path_i18n = self._get_path_i_1_8_n(path)
+        assert bool(id_) ^ bool(callback)  # we must have id_ xor callback defined
+        if id_:
+            menu_item = MenuItemDistant(
+                self.host, type_, path[-1], path_i18n[-1], id_=id_, extra=extra
+            )
+        else:
+            menu_item = MenuItemLocal(
+                type_, path[-1], path_i18n[-1], callback=callback, extra=extra
+            )
+        self.add_menu_item(type_, path[:-1], menu_item, path_i18n[:-1], top_extra)
+
+    def add_menus(self, menus, top_extra=None):
+        """Add several menus at once
+
+        @param menus(iterable): iterable with:
+            id_(unicode,callable): id of distant callback or local callback
+            type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+            path(iterable[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
+            path_i18n(iterable[unicode]):  translated menu path (same lenght as path)
+            extra(dict[unicode,unicode]): dictionary of extra data (used on the leaf menu), can be:
+                - "icon": icon name
+        @param top_extra: same as in [_create_categories]
+        """
+        # TODO: manage icons
+        for id_, type_, path, path_i18n, extra in menus:
+            if callable(id_):
+                self.add_menu(
+                    type_, path, path_i18n, callback=id_, extra=extra, top_extra=top_extra
+                )
+            else:
+                self.add_menu(
+                    type_, path, path_i18n, id_=id_, extra=extra, top_extra=top_extra
+                )
+
+    def add_menu_hook(
+        self, type_, path, path_i18n=None, extra=None, top_extra=None, callback=None
+    ):
+        """Helper method to add a menu hook
+
+        Menu hooks are local menus which override menu given by backend
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
+        @param path_i18n(list[unicode], None):  translated menu path (same lenght as path), or None to get deferred translation
+        @param extra(dict[unicode, unicode], None): same as in [add_menus]
+        @param top_extra: same as in [_create_categories]
+        @param callback(callable): local callback (mutually exclusive with id_)
+        """
+        if path_i18n is None:
+            path_i18n = self._get_path_i_1_8_n(path)
+        menu_item = MenuHook(
+            type_, path[-1], path_i18n[-1], callback=callback, extra=extra
+        )
+        self.add_menu_item(type_, path[:-1], menu_item, path_i18n[:-1], top_extra)
+        log.info("Menu hook set on {path} ({type_})".format(path=path, type_=type_))
+
+    def add_category(self, type_, path, path_i18n=None, extra=None, top_extra=None):
+        """Create a category with all parents, and set extra on the last one
+
+        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
+        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
+        @param path_i18n(list[unicode], None):  translated menu path (same lenght as path), or None to get deferred translation of path
+        @param extra(dict[unicode, unicode], None): same as in [add_menus] (added on the leaf category only)
+        @param top_extra: same as in [_create_categories]
+        @return (MenuCategory): last category add
+        """
+        if path_i18n is None:
+            path_i18n = self._get_path_i_1_8_n(path)
+        last_container = self._create_categories(
+            type_, path, path_i18n, top_extra=top_extra
+        )
+        last_container.set_extra(extra)
+        return last_container
+
+    def get_main_container(self, type_):
+        """Get a main MenuType container
+
+        @param type_: a C.MENU_* constant
+        @return(MenuContainer): the main container
+        """
+        menu_container = self.menus.setdefault(type_, MenuType(type_))
+        return menu_container
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_profile_manager.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,291 @@
+#!/usr/bin/env python3
+
+
+# helper class for making a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
+
+log = logging.getLogger(__name__)
+from libervia.frontends.primitivus.constants import Const as C
+
+
+class ProfileRecord(object):
+    """Class which manage data for one profile"""
+
+    def __init__(self, profile=None, login=None, password=None):
+        self._profile = profile
+        self._login = login
+        self._password = password
+
+    @property
+    def profile(self):
+        return self._profile
+
+    @profile.setter
+    def profile(self, value):
+        self._profile = value
+        # if we change the profile,
+        # we must have no login/password until backend give them
+        self._login = self._password = None
+
+    @property
+    def login(self):
+        return self._login
+
+    @login.setter
+    def login(self, value):
+        self._login = value
+
+    @property
+    def password(self):
+        return self._password
+
+    @password.setter
+    def password(self, value):
+        self._password = value
+
+
+class QuickProfileManager(object):
+    """Class with manage profiles creation/deletion/connection"""
+
+    def __init__(self, host, autoconnect=None):
+        """Create the manager
+
+        @param host: %(doc_host)s
+        @param autoconnect(iterable): list of profiles to connect automatically
+        """
+        self.host = host
+        self._autoconnect = bool(autoconnect)
+        self.current = ProfileRecord()
+
+    def go(self, autoconnect):
+        if self._autoconnect:
+            self.autoconnect(autoconnect)
+
+    def autoconnect(self, profile_keys):
+        """Automatically connect profiles
+
+        @param profile_keys(iterable): list of profile keys to connect
+        """
+        if not profile_keys:
+            log.warning("No profile given to autoconnect")
+            return
+        self._autoconnect = True
+        self._autoconnect_profiles = []
+        self._do_autoconnect(profile_keys)
+
+    def _do_autoconnect(self, profile_keys):
+        """Connect automatically given profiles
+
+        @param profile_kes(iterable): profiles to connect
+        """
+        assert self._autoconnect
+
+        def authenticate_cb(data, cb_id, profile):
+
+            if C.bool(data.pop("validated", C.BOOL_FALSE)):
+                self._autoconnect_profiles.append(profile)
+                if len(self._autoconnect_profiles) == len(profile_keys):
+                    # all the profiles have been validated
+                    self.host.plug_profiles(self._autoconnect_profiles)
+            else:
+                # a profile is not validated, we go to manual mode
+                self._autoconnect = False
+            self.host.action_manager(data, callback=authenticate_cb, profile=profile)
+
+        def get_profile_name_cb(profile):
+            if not profile:
+                # FIXME: this method is not handling manual mode correclty anymore
+                #        must be thought to be handled asynchronously
+                self._autoconnect = False  # manual mode
+                msg = _("Trying to plug an unknown profile key ({})".format(profile_key))
+                log.warning(msg)
+                self.host.show_dialog(_("Profile plugging in error"), msg, "error")
+            else:
+                self.host.action_launch(
+                    C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, profile=profile
+                )
+
+        def get_profile_name_eb(failure):
+            log.error("Can't retrieve profile name: {}".format(failure))
+
+        for profile_key in profile_keys:
+            self.host.bridge.profile_name_get(
+                profile_key, callback=get_profile_name_cb, errback=get_profile_name_eb
+            )
+
+    def get_param_error(self, __):
+        self.host.show_dialog(_("Error"), _("Can't get profile parameter"), "error")
+
+    ## Helping methods ##
+
+    def _get_error_message(self, reason):
+        """Return an error message corresponding to profile creation error
+
+        @param reason (str): reason as returned by profile_create
+        @return (unicode): human readable error message
+        """
+        if reason == "ConflictError":
+            message = _("A profile with this name already exists")
+        elif reason == "CancelError":
+            message = _("Profile creation cancelled by backend")
+        elif reason == "ValueError":
+            message = _(
+                "You profile name is not valid"
+            )  # TODO: print a more informative message (empty name, name starting with '@')
+        else:
+            message = _("Can't create profile ({})").format(reason)
+        return message
+
+    def _delete_profile(self):
+        """Delete the currently selected profile"""
+        if self.current.profile:
+            self.host.bridge.profile_delete_async(
+                self.current.profile, callback=self.refill_profiles
+            )
+            self.reset_fields()
+
+    ## workflow methods (events occuring during the profiles selection) ##
+
+    # These methods must be called by the frontend at some point
+
+    def _on_connect_profiles(self):
+        """Connect the profiles and start the main widget"""
+        if self._autoconnect:
+            self.host.show_dialog(
+                _("Internal error"),
+                _("You can't connect manually and automatically at the same time"),
+                "error",
+            )
+            return
+        self.update_connection_params()
+        profiles = self.get_profiles()
+        if not profiles:
+            self.host.show_dialog(
+                _("No profile selected"),
+                _("You need to create and select at least one profile before connecting"),
+                "error",
+            )
+        else:
+            # All profiles in the list are already validated, so we can plug them directly
+            self.host.plug_profiles(profiles)
+
+    def get_connection_params(self, profile):
+        """Get login and password and display them
+
+        @param profile: %(doc_profile)s
+        """
+        self.host.bridge.param_get_a_async(
+            "JabberID",
+            "Connection",
+            profile_key=profile,
+            callback=self.set_jid,
+            errback=self.get_param_error,
+        )
+        self.host.bridge.param_get_a_async(
+            "Password",
+            "Connection",
+            profile_key=profile,
+            callback=self.set_password,
+            errback=self.get_param_error,
+        )
+
+    def update_connection_params(self):
+        """Check if connection parameters have changed, and update them if so"""
+        if self.current.profile:
+            login = self.get_jid()
+            password = self.getPassword()
+            if login != self.current.login and self.current.login is not None:
+                self.current.login = login
+                self.host.bridge.param_set(
+                    "JabberID", login, "Connection", profile_key=self.current.profile
+                )
+                log.info("login updated for profile [{}]".format(self.current.profile))
+            if password != self.current.password and self.current.password is not None:
+                self.current.password = password
+                self.host.bridge.param_set(
+                    "Password", password, "Connection", profile_key=self.current.profile
+                )
+                log.info(
+                    "password updated for profile [{}]".format(self.current.profile)
+                )
+
+    ## graphic updates (should probably be overriden in frontends) ##
+
+    def reset_fields(self):
+        """Set profile to None, and reset fields"""
+        self.current.profile = None
+        self.set_jid("")
+        self.set_password("")
+
+    def refill_profiles(self):
+        """Rebuild the list of profiles"""
+        profiles = self.host.bridge.profiles_list_get()
+        profiles.sort()
+        self.set_profiles(profiles)
+
+    ## Method which must be implemented by frontends ##
+
+    # get/set data
+
+    def get_profiles(self):
+        """Return list of selected profiles
+
+        Must be implemented by frontends
+        @return (list): list of profiles
+        """
+        raise NotImplementedError
+
+    def set_profiles(self, profiles):
+        """Update the list of profiles"""
+        raise NotImplementedError
+
+    def get_jid(self):
+        """Get current jid
+
+        Must be implemented by frontends
+        @return (unicode): current jabber id
+        """
+        raise NotImplementedError
+
+    def getPassword(self):
+        """Get current password
+
+        Must be implemented by frontends
+        @return (unicode): current password
+        """
+        raise NotImplementedError
+
+    def set_jid(self, jid_):
+        """Set current jid
+
+        Must be implemented by frontends
+        @param jid_(unicode): jabber id to set
+        """
+        raise NotImplementedError
+
+    def set_password(self, password):
+        """Set current password
+
+        Must be implemented by frontends
+        """
+        raise NotImplementedError
+
+    # dialogs
+
+    # Note: a method which check profiles change must be implemented too
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_utils.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+from os.path import exists, splitext
+from optparse import OptionParser
+
+
+def get_new_path(path):
+    """ Check if path exists, and find a non existant path if needed """
+    idx = 2
+    if not exists(path):
+        return path
+    root, ext = splitext(path)
+    while True:
+        new_path = "%s_%d%s" % (root, idx, ext)
+        if not exists(new_path):
+            return new_path
+        idx += 1
+
+
+def check_options():
+    """Check command line options"""
+    usage = _(
+        """
+    %prog [options]
+
+    %prog --help for options list
+    """
+    )
+    parser = OptionParser(usage=usage)  # TODO: use argparse
+
+    parser.add_option("-p", "--profile", help=_("Select the profile to use"))
+
+    (options, args) = parser.parse_args()
+    if options.profile:
+        options.profile = options.profile
+    return options
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_widgets.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,478 @@
+#!/usr/bin/env python3
+
+
+# helper class for making a SAT frontend
+# Copyright (C) 2009-2021 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 libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from libervia.frontends.quick_frontend.constants import Const as C
+
+
+classes_map = {}
+
+
+try:
+    # FIXME: to be removed when an acceptable solution is here
+    str("")  # XXX: unicode doesn't exist in pyjamas
+except (
+    TypeError,
+    AttributeError,
+):  # Error raised is not the same depending on pyjsbuild options
+    str = str
+
+
+def register(base_cls, child_cls=None):
+    """Register a child class to use by default when a base class is needed
+
+    @param base_cls: "Quick..." base class (like QuickChat or QuickContact), must inherit from QuickWidget
+    @param child_cls: inherited class to use when Quick... class is requested, must inherit from base_cls.
+        Can be None if it's the base_cls itself which register
+    """
+    # FIXME: we use base_cls.__name__ instead of base_cls directly because pyjamas because
+    #        in the second case
+    classes_map[base_cls.__name__] = child_cls
+
+
+class WidgetAlreadyExistsError(Exception):
+    pass
+
+
+class QuickWidgetsManager(object):
+    """This class is used to manage all the widgets of a frontend
+    A widget can be a window, a graphical thing, or someting else depending of the frontend"""
+
+    def __init__(self, host):
+        self.host = host
+        self._widgets = {}
+
+    def __iter__(self):
+        """Iterate throught all widgets"""
+        for widget_map in self._widgets.values():
+            for widget_instances in widget_map.values():
+                for widget in widget_instances:
+                    yield widget
+
+    def get_real_class(self, class_):
+        """Return class registered for given class_
+
+        @param class_: subclass of QuickWidget
+        @return: class actually used to create widget
+        """
+        try:
+            # FIXME: we use base_cls.__name__ instead of base_cls directly because pyjamas bugs
+            #        in the second case
+            cls = classes_map[class_.__name__]
+        except KeyError:
+            cls = class_
+        if cls is None:
+            raise exceptions.InternalError(
+                "There is not class registered for {}".format(class_)
+            )
+        return cls
+
+    def get_widget_instances(self, widget):
+        """Get all instance of a widget
+
+        This is a helper method which call get_widgets
+        @param widget(QuickWidget): retrieve instances of this widget
+        @return: iterator on widgets
+        """
+        return self.get_widgets(widget.__class__, widget.target, widget.profiles)
+
+    def get_widgets(self, class_, target=None, profiles=None, with_duplicates=True):
+        """Get all subclassed widgets instances
+
+        @param class_: subclass of QuickWidget, same parameter as used in
+            [get_or_create_widget]
+        @param target: if not None, construct a hash with this target and filter
+            corresponding widgets
+            recreated widgets are handled
+        @param profiles(iterable, None): if not None, filter on instances linked to these
+            profiles
+        @param with_duplicates(bool): if False, only first widget with a given hash is
+            returned
+        @return: iterator on widgets
+        """
+        class_ = self.get_real_class(class_)
+        try:
+            widgets_map = self._widgets[class_.__name__]
+        except KeyError:
+            return
+        else:
+            if target is not None:
+                filter_hash = str(class_.get_widget_hash(target, profiles))
+            else:
+                filter_hash = None
+            if filter_hash is not None:
+                for widget in widgets_map.get(filter_hash, []):
+                    yield widget
+                    if not with_duplicates:
+                        return
+            else:
+                for widget_instances in widgets_map.values():
+                    for widget in widget_instances:
+                        yield widget
+                        if not with_duplicates:
+                            # widgets are set by hashes, so if don't want duplicates
+                            # we only return the first widget of the list
+                            break
+
+    def get_widget(self, class_, target=None, profiles=None):
+        """Get a widget without creating it if it doesn't exist.
+
+        if several instances of widgets with this hash exist, the first one is returned
+        @param class_: subclass of QuickWidget, same parameter as used in [get_or_create_widget]
+        @param target: target depending of the widget, usually a JID instance
+        @param profiles (unicode, iterable[unicode], None): profile(s) to use (may or may not be
+            used, depending of the widget class)
+        @return: a class_ instance or None if the widget doesn't exist
+        """
+        assert (target is not None) or (profiles is not None)
+        if profiles is not None and isinstance(profiles, str):
+            profiles = [profiles]
+        class_ = self.get_real_class(class_)
+        hash_ = class_.get_widget_hash(target, profiles)
+        try:
+            return self._widgets[class_.__name__][hash_][0]
+        except KeyError:
+            return None
+
+    def get_or_create_widget(self, class_, target, *args, **kwargs):
+        """Get an existing widget or create a new one when necessary
+
+        If the widget is new, self.host.new_widget will be called with it.
+        @param class_(class): class of the widget to create
+        @param target: target depending of the widget, usually a JID instance
+        @param args(list): optional args to create a new instance of class_
+        @param kwargs(dict): optional kwargs to create a new instance of class_
+            if 'profile' key is present, it will be popped and put in 'profiles'
+            if there is neither 'profile' nor 'profiles', None will be used for 'profiles'
+            if 'on_new_widget' is present it can have the following values:
+                C.WIDGET_NEW [default]: self.host.new_widget will be called on widget creation
+                [callable]: this method will be called instead of self.host.new_widget
+                None: do nothing
+            if 'on_existing_widget' is present it can have the following values:
+                C.WIDGET_KEEP  [default]: return the existing widget
+                C.WIDGET_RAISE: raise WidgetAlreadyExistsError
+                C.WIDGET_RECREATE: create a new widget
+                    if the existing widget has a "recreate_args" method, it will be called with args list and kwargs dict
+                    so the values can be completed to create correctly the new instance
+                [callable]: this method will be called with existing widget as argument, the widget to use must be returned
+            if 'force_hash' is present, the hash given in value will be used instead of the one returned by class_.get_widget_hash
+            other keys will be used to instanciate class_ if the case happen (e.g. if type_ is present and class_ is a QuickChat subclass,
+                it will be used to create a new QuickChat instance).
+        @return: a class_ instance, either new or already existing
+        """
+        cls = self.get_real_class(class_)
+
+        ## arguments management ##
+        _args = [self.host, target] + list(
+            args
+        ) or []  # FIXME: check if it's really necessary to use optional args
+        _kwargs = kwargs or {}
+        if "profiles" in _kwargs and "profile" in _kwargs:
+            raise ValueError(
+                "You can't have 'profile' and 'profiles' keys at the same time"
+            )
+        try:
+            _kwargs["profiles"] = [_kwargs.pop("profile")]
+        except KeyError:
+            if not "profiles" in _kwargs:
+                _kwargs["profiles"] = None
+
+        # on_new_widget tells what to do for the new widget creation
+        try:
+            on_new_widget = _kwargs.pop("on_new_widget")
+        except KeyError:
+            on_new_widget = C.WIDGET_NEW
+
+        # on_existing_widget tells what to do when the widget already exists
+        try:
+            on_existing_widget = _kwargs.pop("on_existing_widget")
+        except KeyError:
+            on_existing_widget = C.WIDGET_KEEP
+
+        ## we get the hash ##
+        try:
+            hash_ = _kwargs.pop("force_hash")
+        except KeyError:
+            hash_ = cls.get_widget_hash(target, _kwargs["profiles"])
+
+        ## widget creation or retrieval ##
+
+        widgets_map = self._widgets.setdefault(
+            cls.__name__, {}
+        )  # we sorts widgets by classes
+        if not cls.SINGLE:
+            widget = None  # if the class is not SINGLE, we always create a new widget
+        else:
+            try:
+                widget = widgets_map[hash_][0]
+            except KeyError:
+                widget = None
+            else:
+                widget.add_target(target)
+
+        if widget is None:
+            # we need to create a new widget
+            log.debug(f"Creating new widget for target {target} {cls}")
+            widget = cls(*_args, **_kwargs)
+            widgets_map.setdefault(hash_, []).append(widget)
+            self.host.call_listeners("widgetNew", widget)
+
+            if on_new_widget == C.WIDGET_NEW:
+                self.host.new_widget(widget)
+            elif callable(on_new_widget):
+                on_new_widget(widget)
+            else:
+                assert on_new_widget is None
+        else:
+            # the widget already exists
+            if on_existing_widget == C.WIDGET_KEEP:
+                pass
+            elif on_existing_widget == C.WIDGET_RAISE:
+                raise WidgetAlreadyExistsError(hash_)
+            elif on_existing_widget == C.WIDGET_RECREATE:
+                try:
+                    recreate_args = widget.recreate_args
+                except AttributeError:
+                    pass
+                else:
+                    recreate_args(_args, _kwargs)
+                widget = cls(*_args, **_kwargs)
+                widgets_map[hash_].append(widget)
+                log.debug("widget <{wid}> already exists, a new one has been recreated"
+                    .format(wid=widget))
+            elif callable(on_existing_widget):
+                widget = on_existing_widget(widget)
+                if widget is None:
+                    raise exceptions.InternalError(
+                        "on_existing_widget method must return the widget to use")
+                if widget not in widgets_map[hash_]:
+                    log.debug(
+                        "the widget returned by on_existing_widget is new, adding it")
+                    widgets_map[hash_].append(widget)
+            else:
+                raise exceptions.InternalError(
+                    "Unexpected on_existing_widget value ({})".format(on_existing_widget))
+
+        return widget
+
+    def delete_widget(self, widget_to_delete, *args, **kwargs):
+        """Delete a widget instance
+
+        this method must be called by frontends when a widget is deleted
+        widget's on_delete method will be called before deletion, and deletion will be
+        stopped if it returns False.
+        @param widget_to_delete(QuickWidget): widget which need to deleted
+        @param *args: extra arguments to pass to on_delete
+        @param *kwargs: extra keywords arguments to pass to on_delete
+            the extra arguments are not used by QuickFrontend, it's is up to
+            the frontend to use them or not.
+            following extra arguments are well known:
+                - "all_instances" can be used as kwarg, if it evaluate to True,
+                  all instances of the widget will be deleted (if on_delete is
+                  not returning False for any of the instance). This arguments
+                  is not sent to on_delete methods.
+                - "explicit_close" is used when the deletion is requested by
+                  the user or a leave signal, "all_instances" is usually set at
+                  the same time.
+        """
+        # TODO: all_instances must be independante kwargs, this is not possible with Python 2
+        #       but will be with Python 3
+        all_instances = kwargs.get('all_instances', False)
+
+        if all_instances:
+            for w in self.get_widget_instances(widget_to_delete):
+                if w.on_delete(**kwargs) == False:
+                    log.debug(
+                        f"Deletion of {widget_to_delete} cancelled by widget itself")
+                    return
+        else:
+            if widget_to_delete.on_delete(**kwargs) == False:
+                log.debug(f"Deletion of {widget_to_delete} cancelled by widget itself")
+                return
+
+        if self.host.selected_widget == widget_to_delete:
+            self.host.selected_widget = None
+
+        class_ = self.get_real_class(widget_to_delete.__class__)
+        try:
+            widgets_map = self._widgets[class_.__name__]
+        except KeyError:
+            log.error("no widgets_map found for class {cls}".format(cls=class_))
+            return
+        widget_hash = str(class_.get_widget_hash(widget_to_delete.target,
+                                                   widget_to_delete.profiles))
+        try:
+            widget_instances = widgets_map[widget_hash]
+        except KeyError:
+            log.error(f"no instance of {class_.__name__} found with hash {widget_hash!r}")
+            return
+        if all_instances:
+            widget_instances.clear()
+        else:
+            try:
+                widget_instances.remove(widget_to_delete)
+            except ValueError:
+                log.error("widget_to_delete not found in widget instances")
+                return
+
+            log.debug("widget {} deleted".format(widget_to_delete))
+
+        if not widget_instances:
+            # all instances with this hash have been deleted
+            # we remove the hash itself
+            del widgets_map[widget_hash]
+            log.debug("All instances of {cls} with hash {widget_hash!r} have been deleted"
+                .format(cls=class_, widget_hash=widget_hash))
+            self.host.call_listeners("widgetDeleted", widget_to_delete)
+
+
+class QuickWidget(object):
+    """generic widget base"""
+    # FIXME: sometime a single target is used, sometimes several ones
+    #        This should be sorted out in the same way as for profiles: a single
+    #        target should be possible when appropriate attribute is set.
+    #        methods using target(s) and hash should be fixed accordingly
+
+    SINGLE = True  # if True, there can be only one widget per target(s)
+    PROFILES_MULTIPLE = False  # If True, this widget can handle several profiles at once
+    PROFILES_ALLOW_NONE = False  # If True, this widget can be used without profile
+
+    def __init__(self, host, target, profiles=None):
+        """
+        @param host: %(doc_host)s
+        @param target: target specific for this widget class
+        @param profiles: can be either:
+            - (unicode): used when widget class manage a unique profile
+            - (iterable): some widget class can manage several profiles, several at once can be specified here
+            - None: no profile is managed by this widget class (rare)
+        @raise: ValueError when (iterable) or None is given to profiles for a widget class which manage one unique profile.
+        """
+        self.host = host
+        self.targets = set()
+        self.add_target(target)
+        self.profiles = set()
+        self._sync = True
+        if isinstance(profiles, str):
+            self.add_profile(profiles)
+        elif profiles is None:
+            if not self.PROFILES_ALLOW_NONE:
+                raise ValueError("profiles can't have a value of None")
+        else:
+            for profile in profiles:
+                self.add_profile(profile)
+            if not self.profiles:
+                raise ValueError("no profile found, use None for no profile classes")
+
+    @property
+    def profile(self):
+        assert (
+            len(self.profiles) == 1
+            and not self.PROFILES_MULTIPLE
+            and not self.PROFILES_ALLOW_NONE
+        )
+        return list(self.profiles)[0]
+
+    @property
+    def target(self):
+        """Return main target
+
+        A random target is returned when several targets are available
+        """
+        return next(iter(self.targets))
+
+    @property
+    def widget_hash(self):
+        """Return quick widget hash"""
+        return self.get_widget_hash(self.target, self.profiles)
+
+    # synchronisation state
+
+    @property
+    def sync(self):
+        return self._sync
+
+    @sync.setter
+    def sync(self, state):
+        """state of synchronisation with backend
+
+        @param state(bool): True when backend is synchronised
+            False is set by core
+            True must be set by the widget when resynchronisation is finished
+        """
+        self._sync = state
+
+    def resync(self):
+        """Method called when backend can be resynchronized
+
+        The widget has to set self.sync itself when the synchronisation is finished
+        """
+        pass
+
+    # target/profile
+
+    def add_target(self, target):
+        """Add a target if it doesn't already exists
+
+        @param target: target to add
+        """
+        self.targets.add(target)
+
+    def add_profile(self, profile):
+        """Add a profile is if doesn't already exists
+
+        @param profile: profile to add
+        """
+        if self.profiles and not self.PROFILES_MULTIPLE:
+            raise ValueError("multiple profiles are not allowed")
+        self.profiles.add(profile)
+
+    # widget identitication
+
+    @staticmethod
+    def get_widget_hash(target, profiles):
+        """Return the hash associated with this target for this widget class
+
+        some widget classes can manage several target on the same instance
+        (e.g.: a chat widget with multiple resources on the same bare jid),
+        this method allow to return a hash associated to one or several targets
+        to retrieve the good instance. For example, a widget managing JID targets,
+        and all resource of the same bare jid would return the bare jid as hash.
+
+        @param target: target to check
+        @param profiles: profile(s) associated to target, see __init__ docstring
+        @return: a hash (can correspond to one or many targets or profiles, depending of widget class)
+        """
+        return str(target)  # by defaut, there is one hash for one target
+
+    # widget life events
+
+    def on_delete(self, *args, **kwargs):
+        """Called when a widget is being deleted
+
+        @return (boot, None): False to cancel deletion
+            all other value continue deletion
+        """
+        return True
+
+    def on_selected(self):
+        """Called when host.selected_widget is this instance"""
+        pass
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/tools/composition.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+
+
+"""
+Libervia: a Salut à Toi frontend
+Copyright (C) 2013-2016 Adrien Cossa <souliane@mailoo.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/>.
+"""
+
+# Map the messages recipient types to their properties.
+RECIPIENT_TYPES = {
+    "To": {"desc": "Direct recipients", "optional": False},
+    "Cc": {"desc": "Carbon copies", "optional": True},
+    "Bcc": {"desc": "Blind carbon copies", "optional": True},
+}
+
+# Rich text buttons icons and descriptions
+RICH_BUTTONS = {
+    "bold": {"tip": "Bold", "icon": "media/icons/dokuwiki/toolbar/16/bold.png"},
+    "italic": {"tip": "Italic", "icon": "media/icons/dokuwiki/toolbar/16/italic.png"},
+    "underline": {
+        "tip": "Underline",
+        "icon": "media/icons/dokuwiki/toolbar/16/underline.png",
+    },
+    "code": {"tip": "Code", "icon": "media/icons/dokuwiki/toolbar/16/mono.png"},
+    "strikethrough": {
+        "tip": "Strikethrough",
+        "icon": "media/icons/dokuwiki/toolbar/16/strike.png",
+    },
+    "heading": {"tip": "Heading", "icon": "media/icons/dokuwiki/toolbar/16/hequal.png"},
+    "numberedlist": {
+        "tip": "Numbered List",
+        "icon": "media/icons/dokuwiki/toolbar/16/ol.png",
+    },
+    "list": {"tip": "List", "icon": "media/icons/dokuwiki/toolbar/16/ul.png"},
+    "link": {"tip": "Link", "icon": "media/icons/dokuwiki/toolbar/16/linkextern.png"},
+    "horizontalrule": {
+        "tip": "Horizontal rule",
+        "icon": "media/icons/dokuwiki/toolbar/16/hr.png",
+    },
+    "image": {"tip": "Image", "icon": "media/icons/dokuwiki/toolbar/16/image.png"},
+}
+
+# Define here your rich text syntaxes, the key must match the ones used in button.
+# Tupples values must have 3 elements : prefix to the selection or cursor
+# position, sample text to write if the marker is not applied on a selection,
+# suffix to the selection or cursor position.
+# FIXME: must not be hard-coded like this
+RICH_SYNTAXES = {
+    "markdown": {
+        "bold": ("**", "bold", "**"),
+        "italic": ("*", "italic", "*"),
+        "code": ("`", "code", "`"),
+        "heading": ("\n# ", "Heading 1", "\n## Heading 2\n"),
+        "link": ("[desc](", "link", ")"),
+        "list": ("\n* ", "item", "\n    + subitem\n"),
+        "horizontalrule": ("\n***\n", "", ""),
+        "image": ("![desc](", "path", ")"),
+    },
+    "bbcode": {
+        "bold": ("[b]", "bold", "[/b]"),
+        "italic": ("[i]", "italic", "[/i]"),
+        "underline": ("[u]", "underline", "[/u]"),
+        "code": ("[code]", "code", "[/code]"),
+        "strikethrough": ("[s]", "strikethrough", "[/s]"),
+        "link": ("[url=", "link", "]desc[/url]"),
+        "list": ("\n[list] [*]", "item 1", " [*]item 2 [/list]\n"),
+        "image": ('[img alt="desc\]', "path", "[/img]"),
+    },
+    "dokuwiki": {
+        "bold": ("**", "bold", "**"),
+        "italic": ("//", "italic", "//"),
+        "underline": ("__", "underline", "__"),
+        "code": ("<code>", "code", "</code>"),
+        "strikethrough": ("<del>", "strikethrough", "</del>"),
+        "heading": ("\n==== ", "Heading 1", " ====\n=== Heading 2 ===\n"),
+        "link": ("[[", "link", "|desc]]"),
+        "list": ("\n  * ", "item\n", "\n    * subitem\n"),
+        "horizontalrule": ("\n----\n", "", ""),
+        "image": ("{{", "path", " |desc}}"),
+    },
+    "XHTML": {
+        "bold": ("<b>", "bold", "</b>"),
+        "italic": ("<i>", "italic", "</i>"),
+        "underline": ("<u>", "underline", "</u>"),
+        "code": ("<pre>", "code", "</pre>"),
+        "strikethrough": ("<s>", "strikethrough", "</s>"),
+        "heading": ("\n<h3>", "Heading 1", "</h3>\n<h4>Heading 2</h4>\n"),
+        "link": ('<a href="', "link", '">desc</a>'),
+        "list": ("\n<ul><li>", "item 1", "</li><li>item 2</li></ul>\n"),
+        "horizontalrule": ("\n<hr/>\n", "", ""),
+        "image": ('<img src="', "path", '" alt="desc"/>'),
+    },
+}
+
+# Define here the commands that are supported by the WYSIWYG edition.
+# Keys must be the same than the ones used in RICH_SYNTAXES["XHTML"].
+# Values will be used to call execCommand(cmd, False, arg), they can be:
+# - a string used for cmd and arg is assumed empty
+# - a tuple (cmd, prompt, arg) with cmd the name of the command,
+#   prompt the text to display for asking a user input and arg is the
+#   value to use directly without asking the user if prompt is empty.
+COMMANDS = {
+    "bold": "bold",
+    "italic": "italic",
+    "underline": "underline",
+    "code": ("formatBlock", "", "pre"),
+    "strikethrough": "strikeThrough",
+    "heading": ("heading", "Please specify the heading level (h1, h2, h3...)", ""),
+    "link": ("createLink", "Please specify an URL", ""),
+    "list": "insertUnorderedList",
+    "horizontalrule": "insertHorizontalRule",
+    "image": ("insertImage", "Please specify an image path", ""),
+}
+
+# These values should be equal to the ones in plugin_misc_text_syntaxes
+# FIXME: should the plugin import them from here to avoid duplicity? Importing
+# the plugin's values from here is not possible because Libervia would fail.
+PARAM_KEY_COMPOSITION = "Composition"
+PARAM_NAME_SYNTAX = "Syntax"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/tools/css_color.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,245 @@
+#!/usr/bin/env python3
+
+
+# CSS color parsing
+# Copyright (C) 2009-2021 Jérome-Poisson
+
+# 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 libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+CSS_COLORS = {
+    "black": "000000",
+    "silver": "c0c0c0",
+    "gray": "808080",
+    "white": "ffffff",
+    "maroon": "800000",
+    "red": "ff0000",
+    "purple": "800080",
+    "fuchsia": "ff00ff",
+    "green": "008000",
+    "lime": "00ff00",
+    "olive": "808000",
+    "yellow": "ffff00",
+    "navy": "000080",
+    "blue": "0000ff",
+    "teal": "008080",
+    "aqua": "00ffff",
+    "orange": "ffa500",
+    "aliceblue": "f0f8ff",
+    "antiquewhite": "faebd7",
+    "aquamarine": "7fffd4",
+    "azure": "f0ffff",
+    "beige": "f5f5dc",
+    "bisque": "ffe4c4",
+    "blanchedalmond": "ffebcd",
+    "blueviolet": "8a2be2",
+    "brown": "a52a2a",
+    "burlywood": "deb887",
+    "cadetblue": "5f9ea0",
+    "chartreuse": "7fff00",
+    "chocolate": "d2691e",
+    "coral": "ff7f50",
+    "cornflowerblue": "6495ed",
+    "cornsilk": "fff8dc",
+    "crimson": "dc143c",
+    "darkblue": "00008b",
+    "darkcyan": "008b8b",
+    "darkgoldenrod": "b8860b",
+    "darkgray": "a9a9a9",
+    "darkgreen": "006400",
+    "darkgrey": "a9a9a9",
+    "darkkhaki": "bdb76b",
+    "darkmagenta": "8b008b",
+    "darkolivegreen": "556b2f",
+    "darkorange": "ff8c00",
+    "darkorchid": "9932cc",
+    "darkred": "8b0000",
+    "darksalmon": "e9967a",
+    "darkseagreen": "8fbc8f",
+    "darkslateblue": "483d8b",
+    "darkslategray": "2f4f4f",
+    "darkslategrey": "2f4f4f",
+    "darkturquoise": "00ced1",
+    "darkviolet": "9400d3",
+    "deeppink": "ff1493",
+    "deepskyblue": "00bfff",
+    "dimgray": "696969",
+    "dimgrey": "696969",
+    "dodgerblue": "1e90ff",
+    "firebrick": "b22222",
+    "floralwhite": "fffaf0",
+    "forestgreen": "228b22",
+    "gainsboro": "dcdcdc",
+    "ghostwhite": "f8f8ff",
+    "gold": "ffd700",
+    "goldenrod": "daa520",
+    "greenyellow": "adff2f",
+    "grey": "808080",
+    "honeydew": "f0fff0",
+    "hotpink": "ff69b4",
+    "indianred": "cd5c5c",
+    "indigo": "4b0082",
+    "ivory": "fffff0",
+    "khaki": "f0e68c",
+    "lavender": "e6e6fa",
+    "lavenderblush": "fff0f5",
+    "lawngreen": "7cfc00",
+    "lemonchiffon": "fffacd",
+    "lightblue": "add8e6",
+    "lightcoral": "f08080",
+    "lightcyan": "e0ffff",
+    "lightgoldenrodyellow": "fafad2",
+    "lightgray": "d3d3d3",
+    "lightgreen": "90ee90",
+    "lightgrey": "d3d3d3",
+    "lightpink": "ffb6c1",
+    "lightsalmon": "ffa07a",
+    "lightseagreen": "20b2aa",
+    "lightskyblue": "87cefa",
+    "lightslategray": "778899",
+    "lightslategrey": "778899",
+    "lightsteelblue": "b0c4de",
+    "lightyellow": "ffffe0",
+    "limegreen": "32cd32",
+    "linen": "faf0e6",
+    "mediumaquamarine": "66cdaa",
+    "mediumblue": "0000cd",
+    "mediumorchid": "ba55d3",
+    "mediumpurple": "9370db",
+    "mediumseagreen": "3cb371",
+    "mediumslateblue": "7b68ee",
+    "mediumspringgreen": "00fa9a",
+    "mediumturquoise": "48d1cc",
+    "mediumvioletred": "c71585",
+    "midnightblue": "191970",
+    "mintcream": "f5fffa",
+    "mistyrose": "ffe4e1",
+    "moccasin": "ffe4b5",
+    "navajowhite": "ffdead",
+    "oldlace": "fdf5e6",
+    "olivedrab": "6b8e23",
+    "orangered": "ff4500",
+    "orchid": "da70d6",
+    "palegoldenrod": "eee8aa",
+    "palegreen": "98fb98",
+    "paleturquoise": "afeeee",
+    "palevioletred": "db7093",
+    "papayawhip": "ffefd5",
+    "peachpuff": "ffdab9",
+    "peru": "cd853f",
+    "pink": "ffc0cb",
+    "plum": "dda0dd",
+    "powderblue": "b0e0e6",
+    "rosybrown": "bc8f8f",
+    "royalblue": "4169e1",
+    "saddlebrown": "8b4513",
+    "salmon": "fa8072",
+    "sandybrown": "f4a460",
+    "seagreen": "2e8b57",
+    "seashell": "fff5ee",
+    "sienna": "a0522d",
+    "skyblue": "87ceeb",
+    "slateblue": "6a5acd",
+    "slategray": "708090",
+    "slategrey": "708090",
+    "snow": "fffafa",
+    "springgreen": "00ff7f",
+    "steelblue": "4682b4",
+    "tan": "d2b48c",
+    "thistle": "d8bfd8",
+    "tomato": "ff6347",
+    "turquoise": "40e0d0",
+    "violet": "ee82ee",
+    "wheat": "f5deb3",
+    "whitesmoke": "f5f5f5",
+    "yellowgreen": "9acd32",
+    "rebeccapurple": "663399",
+}
+DEFAULT = "000000"
+
+
+def parse(raw_value, as_string=True):
+    """parse CSS color value and return normalised value
+
+    @param raw_value(unicode): CSS value
+    @param as_string(bool): if True return a string,
+        else return a tuple of int
+    @return (unicode, tuple): normalised value
+        if as_string is True, value is 3 or 4 hex words (e.g. u"ff00aabb")
+        else value is a 3 or 4 tuple of int (e.g.: (255, 0, 170, 187)).
+        If present, the 4th value is the alpha channel
+        If value can't be parsed, a warning message is logged, and DEFAULT is returned
+    """
+    raw_value = raw_value.strip().lower()
+    if raw_value.startswith("#"):
+        # we have a hexadecimal value
+        str_value = raw_value[1:]
+        if len(raw_value) in (3, 4):
+            str_value = "".join([2 * v for v in str_value])
+    elif raw_value.startswith("rgb"):
+        left_p = raw_value.find("(")
+        right_p = raw_value.find(")")
+        rgb_values = [v.strip() for v in raw_value[left_p + 1 : right_p].split(",")]
+        expected_len = 4 if raw_value.startswith("rgba") else 3
+        if len(rgb_values) != expected_len:
+            log.warning("incorrect value: {}".format(raw_value))
+            str_value = DEFAULT
+        else:
+            int_values = []
+            for rgb_v in rgb_values:
+                p_idx = rgb_v.find("%")
+                if p_idx == -1:
+                    # base 10 value
+                    try:
+                        int_v = int(rgb_v)
+                        if int_v > 255:
+                            raise ValueError("value exceed 255")
+                        int_values.append(int_v)
+                    except ValueError:
+                        log.warning("invalid int: {}".format(rgb_v))
+                        int_values.append(0)
+                else:
+                    # percentage
+                    try:
+                        int_v = int(int(rgb_v[:p_idx]) / 100.0 * 255)
+                        if int_v > 255:
+                            raise ValueError("value exceed 255")
+                        int_values.append(int_v)
+                    except ValueError:
+                        log.warning("invalid percent value: {}".format(rgb_v))
+                        int_values.append(0)
+            str_value = "".join(["{:02x}".format(v) for v in int_values])
+    elif raw_value.startswith("hsl"):
+        log.warning("hue-saturation-lightness not handled yet")  # TODO
+        str_value = DEFAULT
+    else:
+        try:
+            str_value = CSS_COLORS[raw_value]
+        except KeyError:
+            log.warning("unrecognised format: {}".format(raw_value))
+            str_value = DEFAULT
+
+    if as_string:
+        return str_value
+    else:
+        return tuple(
+            [
+                int(str_value[i] + str_value[i + 1], 16)
+                for i in range(0, len(str_value), 2)
+            ]
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/tools/games.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 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/>.
+
+"""This library help manage general games (e.g. card games) and it is shared by the frontends"""
+
+SUITS_ORDER = [
+    "pique",
+    "coeur",
+    "trefle",
+    "carreau",
+    "atout",
+]  # I have switched the usual order 'trefle' and 'carreau' because card are more easy to see if suit colour change (black, red, black, red)
+VALUES_ORDER = [str(i) for i in range(1, 11)] + ["valet", "cavalier", "dame", "roi"]
+
+
+class TarotCard(object):
+    """This class is used to represent a car logically"""
+
+    def __init__(self, tuple_card):
+        """@param tuple_card: tuple (suit, value)"""
+        self.suit, self.value = tuple_card
+        self.bout = self.suit == "atout" and self.value in ["1", "21", "excuse"]
+        if self.bout or self.value == "roi":
+            self.points = 4.5
+        elif self.value == "dame":
+            self.points = 3.5
+        elif self.value == "cavalier":
+            self.points = 2.5
+        elif self.value == "valet":
+            self.points = 1.5
+        else:
+            self.points = 0.5
+
+    def get_tuple(self):
+        return (self.suit, self.value)
+
+    @staticmethod
+    def from_tuples(tuple_list):
+        result = []
+        for card_tuple in tuple_list:
+            result.append(TarotCard(card_tuple))
+        return result
+
+    def __cmp__(self, other):
+        if other is None:
+            return 1
+        if self.suit != other.suit:
+            idx1 = SUITS_ORDER.index(self.suit)
+            idx2 = SUITS_ORDER.index(other.suit)
+            return idx1.__cmp__(idx2)
+        if self.suit == "atout":
+            if self.value == other.value == "excuse":
+                return 0
+            if self.value == "excuse":
+                return -1
+            if other.value == "excuse":
+                return 1
+            return int(self.value).__cmp__(int(other.value))
+        # at this point we have the same suit which is not 'atout'
+        idx1 = VALUES_ORDER.index(self.value)
+        idx2 = VALUES_ORDER.index(other.value)
+        return idx1.__cmp__(idx2)
+
+    def __str__(self):
+        return "[%s,%s]" % (self.suit, self.value)
+
+
+# These symbols are diplayed by Libervia next to the player's nicknames
+SYMBOLS = {"Radiocol": ["♬"], "Tarot": ["♠", "♣", "♥", "♦"]}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/tools/host_listener.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 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/>.
+
+"""This module is only used launch callbacks when host is ready, used for early initialisation stuffs"""
+
+
+listeners = []
+
+
+def addListener(cb):
+    """Add a listener which will be called when host is ready
+
+    @param cb: callback which will be called when host is ready with host as only argument
+    """
+    listeners.append(cb)
+
+
+def call_listeners(host):
+    """Must be called by frontend when host is ready.
+
+    The call will launch all the callbacks, then remove the listeners list.
+    @param host(QuickApp): the instancied QuickApp subclass
+    """
+    global listeners
+    while True:
+        try:
+            cb = listeners.pop(0)
+            cb(host)
+        except IndexError:
+            break
+    del listeners
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/tools/jid.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+
+# Libervia XMPP
+# Copyright (C) 2009-2023 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 typing import Optional, Tuple
+
+
+class JID(str):
+    """This class helps manage JID (<local>@<domain>/<resource>)"""
+
+    def __new__(cls, jid_str: str) -> "JID":
+        return str.__new__(cls, cls._normalize(jid_str))
+
+    def __init__(self, jid_str: str):
+        self._node, self._domain, self._resource = self._parse()
+
+    @staticmethod
+    def _normalize(jid_str: str) -> str:
+        """Naive normalization before instantiating and parsing the JID"""
+        if not jid_str:
+            return jid_str
+        tokens = jid_str.split("/")
+        tokens[0] = tokens[0].lower()  # force node and domain to lower-case
+        return "/".join(tokens)
+
+    def _parse(self) -> Tuple[Optional[str], str, Optional[str]]:
+        """Find node, domain, and resource from JID"""
+        node_end = self.find("@")
+        if node_end < 0:
+            node_end = 0
+        domain_end = self.find("/")
+        if domain_end == 0:
+            raise ValueError("a jid can't start with '/'")
+        if domain_end == -1:
+            domain_end = len(self)
+        node = self[:node_end] or None
+        domain = self[(node_end + 1) if node_end else 0 : domain_end]
+        resource = self[domain_end + 1 :] or None
+        return node, domain, resource
+
+    @property
+    def node(self) -> Optional[str]:
+        return self._node
+
+    @property
+    def local(self) -> Optional[str]:
+        return self._node
+
+    @property
+    def domain(self) -> str:
+        return self._domain
+
+    @property
+    def resource(self) -> Optional[str]:
+        return self._resource
+
+    @property
+    def bare(self) -> "JID":
+        if not self.node:
+            return JID(self.domain)
+        return JID(f"{self.node}@{self.domain}")
+
+    def change_resource(self, resource: str) -> "JID":
+        """Build a new JID with the same node and domain but a different resource.
+
+        @param resource: The new resource for the JID.
+        @return: A new JID instance with the updated resource.
+        """
+        return JID(f"{self.bare}/{resource}")
+
+    def is_valid(self) -> bool:
+        """
+        @return: True if the JID is XMPP compliant
+        """
+        # Simple check for domain part
+        if not self.domain or self.domain.startswith(".") or self.domain.endswith("."):
+            return False
+        if ".." in self.domain:
+            return False
+        return True
+
+
+def new_resource(entity: JID, resource: str) -> JID:
+    """Build a new JID from the given entity and resource.
+
+    @param entity: original JID
+    @param resource: new resource
+    @return: a new JID instance
+    """
+    return entity.change_resource(resource)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/tools/misc.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+
+
+# SAT helpers methods for plugins
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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/>.
+
+
+class InputHistory(object):
+    def _update_input_history(self, text=None, step=None, callback=None, mode=""):
+        """Update the lists of previously sent messages. Several lists can be
+        handled as they are stored in a dictionary, the argument "mode" being
+        used as the entry key. There's also a temporary list to allow you play
+        with previous entries before sending a new message. Parameters values
+        can be combined: text is None and step is None to initialize a main
+        list and the temporary one, step is None to update a list and
+        reinitialize the temporary one, step is not None to update
+        the temporary list between two messages.
+        @param text: text to be saved.
+        @param step: step to move the temporary index.
+        @param callback: method to display temporary entries.
+        @param mode: the dictionary key for main lists.
+        """
+        if not hasattr(self, "input_histories"):
+            self.input_histories = {}
+        history = self.input_histories.setdefault(mode, [])
+        if step is None and text is not None:
+            # update the main list
+            if text in history:
+                history.remove(text)
+            history.append(text)
+        length = len(history)
+        if step is None or length == 0:
+            # prepare the temporary list and index
+            self.input_history_tmp = history[:]
+            self.input_history_tmp.append("")
+            self.input_history_index = length
+        if step is None:
+            return
+        # update the temporary list
+        if text is not None:
+            # save the current entry
+            self.input_history_tmp[self.input_history_index] = text
+        # move to another entry if possible
+        index_tmp = self.input_history_index + step
+        if index_tmp >= 0 and index_tmp < len(self.input_history_tmp):
+            if callback is not None:
+                callback(self.input_history_tmp[index_tmp])
+            self.input_history_index = index_tmp
+
+
+class FlagsHandler(object):
+    """Small class to handle easily option flags
+
+    the instance is initialized with an iterable
+    then attribute return True if flag is set, False else.
+    """
+
+    def __init__(self, flags):
+        self.flags = set(flags or [])
+        self._used_flags = set()
+
+    def __getattr__(self, flag):
+        self._used_flags.add(flag)
+        return flag in self.flags
+
+    def __getitem__(self, flag):
+        return getattr(self, flag)
+
+    def __len__(self):
+        return len(self.flags)
+
+    def __iter__(self):
+        return self.flags.__iter__()
+
+    @property
+    def all_used(self):
+        """Return True if all flags have been used"""
+        return self._used_flags.issuperset(self.flags)
+
+    @property
+    def unused(self):
+        """Return flags which has not been used yet"""
+        return self.flags.difference(self._used_flags)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/tools/strings.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+
+
+# SAT helpers methods for plugins
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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/>.
+
+import re
+
+# Regexp from http://daringfireball.net/2010/07/improved_regex_for_matching_urls
+RE_URL = re.compile(
+    r"""(?i)\b((?:[a-z]{3,}://|(www|ftp)\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/|mailto:|xmpp:|geo:)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?]))"""
+)
+
+
+# TODO: merge this class with an other module or at least rename it (strings is not a good name)
+
+
+def get_url_params(url):
+    """This comes from pyjamas.Location.makeUrlDict with a small change
+    to also parse full URLs, and parameters with no value specified
+    (in that case the default value "" is used).
+    @param url: any URL with or without parameters
+    @return: a dictionary of the parameters, if any was given, or {}
+    """
+    dict_ = {}
+    if "/" in url:
+        # keep the part after the last "/"
+        url = url[url.rindex("/") + 1 :]
+    if url.startswith("?"):
+        # remove the first "?"
+        url = url[1:]
+    pairs = url.split("&")
+    for pair in pairs:
+        if len(pair) < 3:
+            continue
+        kv = pair.split("=", 1)
+        dict_[kv[0]] = kv[1] if len(kv) > 1 else ""
+    return dict_
+
+
+def add_url_to_text(string, new_target=True):
+    """Check a text for what looks like an URL and make it clickable.
+
+    @param string (unicode): text to process
+    @param new_target (bool): if True, make the link open in a new window
+    """
+    # XXX: report any change to libervia.browser.strings.add_url_to_text
+    def repl(match):
+        url = match.group(0)
+        if not re.match(r"""[a-z]{3,}://|mailto:|xmpp:""", url):
+            url = "http://" + url
+        target = ' target="_blank"' if new_target else ""
+        return '<a href="%s"%s class="url">%s</a>' % (url, target, match.group(0))
+
+    return RE_URL.sub(repl, string)
+
+
+def add_url_to_image(string):
+    """Check a XHTML text for what looks like an imageURL and make it clickable.
+
+    @param string (unicode): text to process
+    """
+    # XXX: report any change to libervia.browser.strings.add_url_to_image
+    def repl(match):
+        url = match.group(1)
+        return '<a href="%s" target="_blank">%s</a>' % (url, match.group(0))
+
+    pattern = r"""<img[^>]* src="([^"]+)"[^>]*>"""
+    return re.sub(pattern, repl, string)
+
+
+def fix_xhtml_links(xhtml):
+    """Add http:// if the scheme is missing and force opening in a new window.
+
+    @param string (unicode): XHTML Content
+    """
+    subs = []
+    for match in re.finditer(r'<a( \w+="[^"]*")* ?/?>', xhtml):
+        tag = match.group(0)
+        url = re.search(r'href="([^"]*)"', tag)
+        if url and not url.group(1).startswith("#"):  # skip internal anchor
+            if not re.search(r'target="([^"]*)"', tag):  # no target
+                subs.append((tag, '<a target="_blank"%s' % tag[2:]))
+            if not re.match(r"^\w+://", url.group(1)):  # no scheme
+                subs.append((url.group(0), 'href="http://%s"' % url.group(1)))
+
+    for url, new_url in subs:
+        xhtml = xhtml.replace(url, new_url)
+    return xhtml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/tools/xmltools.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 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/>.
+
+"""This library help manage XML used in SàT frontends """
+
+# we don't import minidom as a different class can be used in frontends
+# (e.g. NativeDOM in Libervia)
+
+
+def inline_root(doc):
+    """ make the root attribute inline
+    @param root_node: minidom's Document compatible class
+    @return: plain XML
+    """
+    root_elt = doc.documentElement
+    if root_elt.hasAttribute("style"):
+        styles_raw = root_elt.getAttribute("style")
+        styles = styles_raw.split(";")
+        new_styles = []
+        for style in styles:
+            try:
+                key, value = style.split(":")
+            except ValueError:
+                continue
+            if key.strip().lower() == "display":
+                value = "inline"
+            new_styles.append("%s: %s" % (key.strip(), value.strip()))
+        root_elt.setAttribute("style", "; ".join(new_styles))
+    else:
+        root_elt.setAttribute("style", "display: inline")
+    return root_elt.toxml()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/tools/xmlui.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1149 @@
+#!/usr/bin/env python3
+
+
+# SàT frontend tools
+# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.frontends.quick_frontend.constants import Const as C
+from libervia.backend.core import exceptions
+
+
+_class_map = {}
+CLASS_PANEL = "panel"
+CLASS_DIALOG = "dialog"
+CURRENT_LABEL = "current_label"
+HIDDEN = "hidden"
+
+
+class InvalidXMLUI(Exception):
+    pass
+
+
+class ClassNotRegistedError(Exception):
+    pass
+
+
+# FIXME: this method is duplicated in frontends.tools.xmlui.get_text
+def get_text(node):
+    """Get child text nodes
+    @param node: dom Node
+    @return: joined unicode text of all nodes
+
+    """
+    data = []
+    for child in node.childNodes:
+        if child.nodeType == child.TEXT_NODE:
+            data.append(child.wholeText)
+    return "".join(data)
+
+
+class Widget(object):
+    """base Widget"""
+
+    pass
+
+
+class EmptyWidget(Widget):
+    """Just a placeholder widget"""
+
+    pass
+
+
+class TextWidget(Widget):
+    """Non interactive text"""
+
+    pass
+
+
+class LabelWidget(Widget):
+    """Non interactive text"""
+
+    pass
+
+
+class JidWidget(Widget):
+    """Jabber ID"""
+
+    pass
+
+
+class DividerWidget(Widget):
+    """Separator"""
+
+    pass
+
+
+class StringWidget(Widget):
+    """Input widget wich require a string
+
+    often called Edit in toolkits
+    """
+
+    pass
+
+
+class JidInputWidget(Widget):
+    """Input widget wich require a string
+
+    often called Edit in toolkits
+    """
+
+    pass
+
+
+class PasswordWidget(Widget):
+    """Input widget with require a masked string"""
+
+    pass
+
+
+class TextBoxWidget(Widget):
+    """Input widget with require a long, possibly multilines string
+
+    often called TextArea in toolkits
+    """
+
+    pass
+
+
+class XHTMLBoxWidget(Widget):
+    """Input widget specialised in XHTML editing,
+
+    a WYSIWYG or specialised editor is expected
+    """
+
+    pass
+
+
+class BoolWidget(Widget):
+    """Input widget with require a boolean value
+    often called CheckBox in toolkits
+    """
+
+    pass
+
+
+class IntWidget(Widget):
+    """Input widget with require an integer"""
+
+    pass
+
+
+class ButtonWidget(Widget):
+    """A clickable widget"""
+
+    pass
+
+
+class ListWidget(Widget):
+    """A widget able to show/choose one or several strings in a list"""
+
+    pass
+
+
+class JidsListWidget(Widget):
+    """A widget able to show/choose one or several strings in a list"""
+
+    pass
+
+
+class Container(Widget):
+    """Widget which can contain other ones with a specific layout"""
+
+    @classmethod
+    def _xmlui_adapt(cls, instance):
+        """Make cls as instance.__class__
+
+        cls must inherit from original instance class
+        Usefull when you get a class from UI toolkit
+        """
+        assert instance.__class__ in cls.__bases__
+        instance.__class__ = type(cls.__name__, cls.__bases__, dict(cls.__dict__))
+
+
+class PairsContainer(Container):
+    """Widgets are disposed in rows of two (usually label/input)"""
+
+    pass
+
+
+class LabelContainer(Container):
+    """Widgets are associated with label or empty widget"""
+
+    pass
+
+
+class TabsContainer(Container):
+    """A container which several other containers in tabs
+
+    Often called Notebook in toolkits
+    """
+
+    pass
+
+
+class VerticalContainer(Container):
+    """Widgets are disposed vertically"""
+
+    pass
+
+
+class AdvancedListContainer(Container):
+    """Widgets are disposed in rows with advaned features"""
+
+    pass
+
+
+class Dialog(object):
+    """base dialog"""
+
+    def __init__(self, _xmlui_parent):
+        self._xmlui_parent = _xmlui_parent
+
+    def _xmlui_validated(self, data=None):
+        if data is None:
+            data = {}
+        self._xmlui_set_data(C.XMLUI_STATUS_VALIDATED, data)
+        self._xmlui_submit(data)
+
+    def _xmlui_cancelled(self):
+        data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE}
+        self._xmlui_set_data(C.XMLUI_STATUS_CANCELLED, data)
+        self._xmlui_submit(data)
+
+    def _xmlui_submit(self, data):
+        if self._xmlui_parent.submit_id is None:
+            log.debug(_("Nothing to submit"))
+        else:
+            self._xmlui_parent.submit(data)
+
+    def _xmlui_set_data(self, status, data):
+        pass
+
+
+class MessageDialog(Dialog):
+    """Dialog with a OK/Cancel type configuration"""
+
+    pass
+
+
+class NoteDialog(Dialog):
+    """Short message which doesn't need user confirmation to disappear"""
+
+    pass
+
+
+class ConfirmDialog(Dialog):
+    """Dialog with a OK/Cancel type configuration"""
+
+    def _xmlui_set_data(self, status, data):
+        if status == C.XMLUI_STATUS_VALIDATED:
+            data[C.XMLUI_DATA_ANSWER] = C.BOOL_TRUE
+        elif status == C.XMLUI_STATUS_CANCELLED:
+            data[C.XMLUI_DATA_ANSWER] = C.BOOL_FALSE
+
+
+class FileDialog(Dialog):
+    """Dialog with a OK/Cancel type configuration"""
+
+    pass
+
+
+class XMLUIBase(object):
+    """Base class to construct SàT XML User Interface
+
+    This class must not be instancied directly
+    """
+
+    def __init__(self, host, parsed_dom, title=None, flags=None, callback=None,
+                 profile=C.PROF_KEY_NONE):
+        """Initialise the XMLUI instance
+
+        @param host: %(doc_host)s
+        @param parsed_dom: main parsed dom
+        @param title: force the title, or use XMLUI one if None
+        @param flags: list of string which can be:
+            - NO_CANCEL: the UI can't be cancelled
+            - FROM_BACKEND: the UI come from backend (i.e. it's not the direct result of
+                            user operation)
+        @param callback(callable, None): if not None, will be used with action_launch:
+            - if None is used, default behaviour will be used (closing the dialog and
+              calling host.action_manager)
+            - if a callback is provided, it will be used instead, so you'll have to manage
+                dialog closing or new xmlui to display, or other action (you can call
+                host.action_manager)
+                The callback will have data, callback_id and profile as arguments
+        """
+        self.host = host
+        top = parsed_dom.documentElement
+        self.session_id = top.getAttribute("session_id") or None
+        self.submit_id = top.getAttribute("submit") or None
+        self.xmlui_title = title or top.getAttribute("title") or ""
+        self.hidden = {}
+        if flags is None:
+            flags = []
+        self.flags = flags
+        self.callback = callback or self._default_cb
+        self.profile = profile
+
+    @property
+    def user_action(self):
+        return "FROM_BACKEND" not in self.flags
+
+    def _default_cb(self, data, cb_id, profile):
+        # TODO: when XMLUI updates will be managed, the _xmlui_close
+        #       must be called only if there is no update
+        self._xmlui_close()
+        self.host.action_manager(data, profile=profile)
+
+    def _is_attr_set(self, name, node):
+        """Return widget boolean attribute status
+
+        @param name: name of the attribute (e.g. "read_only")
+        @param node: Node instance
+        @return (bool): True if widget's attribute is set (C.BOOL_TRUE)
+        """
+        read_only = node.getAttribute(name) or C.BOOL_FALSE
+        return read_only.lower().strip() == C.BOOL_TRUE
+
+    def _get_child_node(self, node, name):
+        """Return the first child node with the given name
+
+        @param node: Node instance
+        @param name: name of the wanted node
+
+        @return: The found element or None
+        """
+        for child in node.childNodes:
+            if child.nodeName == name:
+                return child
+        return None
+
+    def submit(self, data):
+        self._xmlui_close()
+        if self.submit_id is None:
+            raise ValueError("Can't submit is self.submit_id is not set")
+        if "session_id" in data:
+            raise ValueError(
+                "session_id must no be used in data, it is automaticaly filled with "
+                "self.session_id if present"
+            )
+        if self.session_id is not None:
+            data["session_id"] = self.session_id
+        self._xmlui_launch_action(self.submit_id, data)
+
+    def _xmlui_launch_action(self, action_id, data):
+        self.host.action_launch(
+            action_id, data, callback=self.callback, profile=self.profile
+        )
+
+    def _xmlui_close(self):
+        """Close the window/popup/... where the constructor XMLUI is
+
+        this method must be overrided
+        """
+        raise NotImplementedError
+
+
+class ValueGetter(object):
+    """dict like object which return values of widgets"""
+    # FIXME: widget which can keep multiple values are not handled
+
+    def __init__(self, widgets, attr="value"):
+        self.attr = attr
+        self.widgets = widgets
+
+    def __getitem__(self, name):
+        return getattr(self.widgets[name], self.attr)
+
+    def __getattr__(self, name):
+        return self.__getitem__(name)
+
+    def keys(self):
+        return list(self.widgets.keys())
+
+    def items(self):
+        for name, widget in self.widgets.items():
+            try:
+                value = widget.value
+            except AttributeError:
+                try:
+                    value = list(widget.values)
+                except AttributeError:
+                    continue
+            yield name, value
+
+
+class XMLUIPanel(XMLUIBase):
+    """XMLUI Panel
+
+    New frontends can inherit this class to easily implement XMLUI
+    @property widget_factory: factory to create frontend-specific widgets
+    @property dialog_factory: factory to create frontend-specific dialogs
+    """
+
+    widget_factory = None
+
+    def __init__(self, host, parsed_dom, title=None, flags=None, callback=None,
+                 ignore=None, whitelist=None, profile=C.PROF_KEY_NONE):
+        """
+
+        @param title(unicode, None): title of the
+        @property widgets(dict): widget name => widget map
+        @property widget_value(ValueGetter): retrieve widget value from it's name
+        """
+        super(XMLUIPanel, self).__init__(
+            host, parsed_dom, title=title, flags=flags, callback=callback, profile=profile
+        )
+        self.ctrl_list = {}  # input widget, used mainly for forms
+        self.widgets = {}  #  allow to access any named widgets
+        self.widget_value = ValueGetter(self.widgets)
+        self._main_cont = None
+        if ignore is None:
+            ignore = []
+        self._ignore = ignore
+        if whitelist is not None:
+            if ignore:
+                raise exceptions.InternalError(
+                    "ignore and whitelist must not be used at the same time"
+                )
+            self._whitelist = whitelist
+        else:
+            self._whitelist = None
+        self.construct_ui(parsed_dom)
+
+    @staticmethod
+    def escape(name):
+        """Return escaped name for forms"""
+        return "%s%s" % (C.SAT_FORM_PREFIX, name)
+
+    @property
+    def main_cont(self):
+        return self._main_cont
+
+    @property
+    def values(self):
+        """Dict of all widgets values"""
+        return dict(self.widget_value.items())
+
+    @main_cont.setter
+    def main_cont(self, value):
+        if self._main_cont is not None:
+            raise ValueError(_("XMLUI can have only one main container"))
+        self._main_cont = value
+
+    def _parse_childs(self, _xmlui_parent, current_node, wanted=("container",), data=None):
+        """Recursively parse childNodes of an element
+
+        @param _xmlui_parent: widget container with '_xmlui_append' method
+        @param current_node: element from which childs will be parsed
+        @param wanted: list of tag names that can be present in the childs to be SàT XMLUI
+                       compliant
+        @param data(None, dict): additionnal data which are needed in some cases
+        """
+        for node in current_node.childNodes:
+            if data is None:
+                data = {}
+            if wanted and not node.nodeName in wanted:
+                raise InvalidXMLUI("Unexpected node: [%s]" % node.nodeName)
+
+            if node.nodeName == "container":
+                type_ = node.getAttribute("type")
+                if _xmlui_parent is self and type_ not in ("vertical", "tabs"):
+                    # main container is not a VerticalContainer and we want one,
+                    # so we create one to wrap it
+                    _xmlui_parent = self.widget_factory.createVerticalContainer(self)
+                    self.main_cont = _xmlui_parent
+                if type_ == "tabs":
+                    cont = self.widget_factory.createTabsContainer(_xmlui_parent)
+                    self._parse_childs(_xmlui_parent, node, ("tab",), {"tabs_cont": cont})
+                elif type_ == "vertical":
+                    cont = self.widget_factory.createVerticalContainer(_xmlui_parent)
+                    self._parse_childs(cont, node, ("widget", "container"))
+                elif type_ == "pairs":
+                    cont = self.widget_factory.createPairsContainer(_xmlui_parent)
+                    self._parse_childs(cont, node, ("widget", "container"))
+                elif type_ == "label":
+                    cont = self.widget_factory.createLabelContainer(_xmlui_parent)
+                    self._parse_childs(
+                        # FIXME: the "None" value for CURRENT_LABEL doesn't seem
+                        #        used or even useful, it should probably be removed
+                        #        and all "is not None" tests for it should be removed too
+                        #        to be checked for 0.8
+                        cont, node, ("widget", "container"), {CURRENT_LABEL: None}
+                    )
+                elif type_ == "advanced_list":
+                    try:
+                        columns = int(node.getAttribute("columns"))
+                    except (TypeError, ValueError):
+                        raise exceptions.DataError("Invalid columns")
+                    selectable = node.getAttribute("selectable") or "no"
+                    auto_index = node.getAttribute("auto_index") == C.BOOL_TRUE
+                    data = {"index": 0} if auto_index else None
+                    cont = self.widget_factory.createAdvancedListContainer(
+                        _xmlui_parent, columns, selectable
+                    )
+                    callback_id = node.getAttribute("callback") or None
+                    if callback_id is not None:
+                        if selectable == "no":
+                            raise ValueError(
+                                "can't have selectable=='no' and callback_id at the same time"
+                            )
+                        cont._xmlui_callback_id = callback_id
+                        cont._xmlui_on_select(self.on_adv_list_select)
+
+                    self._parse_childs(cont, node, ("row",), data)
+                else:
+                    log.warning(_("Unknown container [%s], using default one") % type_)
+                    cont = self.widget_factory.createVerticalContainer(_xmlui_parent)
+                    self._parse_childs(cont, node, ("widget", "container"))
+                try:
+                    xmluiAppend = _xmlui_parent._xmlui_append
+                except (
+                    AttributeError,
+                    TypeError,
+                ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
+                    if _xmlui_parent is self:
+                        self.main_cont = cont
+                    else:
+                        raise Exception(
+                            _("Internal Error, container has not _xmlui_append method")
+                        )
+                else:
+                    xmluiAppend(cont)
+
+            elif node.nodeName == "tab":
+                name = node.getAttribute("name")
+                label = node.getAttribute("label")
+                selected = C.bool(node.getAttribute("selected") or C.BOOL_FALSE)
+                if not name or not "tabs_cont" in data:
+                    raise InvalidXMLUI
+                if self.type == "param":
+                    self._current_category = (
+                        name
+                    )  # XXX: awful hack because params need category and we don't keep parent
+                tab_cont = data["tabs_cont"]
+                new_tab = tab_cont._xmlui_add_tab(label or name, selected)
+                self._parse_childs(new_tab, node, ("widget", "container"))
+
+            elif node.nodeName == "row":
+                try:
+                    index = str(data["index"])
+                except KeyError:
+                    index = node.getAttribute("index") or None
+                else:
+                    data["index"] += 1
+                _xmlui_parent._xmlui_add_row(index)
+                self._parse_childs(_xmlui_parent, node, ("widget", "container"))
+
+            elif node.nodeName == "widget":
+                name = node.getAttribute("name")
+                if name and (
+                    name in self._ignore
+                    or self._whitelist is not None
+                    and name not in self._whitelist
+                ):
+                    # current widget is ignored, but there may be already a label
+                    if CURRENT_LABEL in data:
+                        curr_label = data.pop(CURRENT_LABEL)
+                        if curr_label is not None:
+                            # if so, we remove it from parent
+                            _xmlui_parent._xmlui_remove(curr_label)
+                    continue
+                type_ = node.getAttribute("type")
+                value_elt = self._get_child_node(node, "value")
+                if value_elt is not None:
+                    value = get_text(value_elt)
+                else:
+                    value = (
+                        node.getAttribute("value") if node.hasAttribute("value") else ""
+                    )
+                if type_ == "empty":
+                    ctrl = self.widget_factory.createEmptyWidget(_xmlui_parent)
+                    if CURRENT_LABEL in data:
+                        data[CURRENT_LABEL] = None
+                elif type_ == "text":
+                    ctrl = self.widget_factory.createTextWidget(_xmlui_parent, value)
+                elif type_ == "label":
+                    ctrl = self.widget_factory.createLabelWidget(_xmlui_parent, value)
+                    data[CURRENT_LABEL] = ctrl
+                elif type_ == "hidden":
+                    if name in self.hidden:
+                        raise exceptions.ConflictError("Conflict on hidden value with "
+                                                       "name {name}".format(name=name))
+                    self.hidden[name] = value
+                    continue
+                elif type_ == "jid":
+                    ctrl = self.widget_factory.createJidWidget(_xmlui_parent, value)
+                elif type_ == "divider":
+                    style = node.getAttribute("style") or "line"
+                    ctrl = self.widget_factory.createDividerWidget(_xmlui_parent, style)
+                elif type_ == "string":
+                    ctrl = self.widget_factory.createStringWidget(
+                        _xmlui_parent, value, self._is_attr_set("read_only", node)
+                    )
+                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
+                elif type_ == "jid_input":
+                    ctrl = self.widget_factory.createJidInputWidget(
+                        _xmlui_parent, value, self._is_attr_set("read_only", node)
+                    )
+                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
+                elif type_ == "password":
+                    ctrl = self.widget_factory.createPasswordWidget(
+                        _xmlui_parent, value, self._is_attr_set("read_only", node)
+                    )
+                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
+                elif type_ == "textbox":
+                    ctrl = self.widget_factory.createTextBoxWidget(
+                        _xmlui_parent, value, self._is_attr_set("read_only", node)
+                    )
+                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
+                elif type_ == "xhtmlbox":
+                    ctrl = self.widget_factory.createXHTMLBoxWidget(
+                        _xmlui_parent, value, self._is_attr_set("read_only", node)
+                    )
+                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
+                elif type_ == "bool":
+                    ctrl = self.widget_factory.createBoolWidget(
+                        _xmlui_parent,
+                        value == C.BOOL_TRUE,
+                        self._is_attr_set("read_only", node),
+                    )
+                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
+                elif type_ == "int":
+                    ctrl = self.widget_factory.createIntWidget(
+                        _xmlui_parent, value, self._is_attr_set("read_only", node)
+                    )
+                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
+                elif type_ == "list":
+                    style = [] if node.getAttribute("multi") == "yes" else ["single"]
+                    for attr in ("noselect", "extensible", "reducible", "inline"):
+                        if node.getAttribute(attr) == "yes":
+                            style.append(attr)
+                    _options = [
+                        (option.getAttribute("value"), option.getAttribute("label"))
+                        for option in node.getElementsByTagName("option")
+                    ]
+                    _selected = [
+                        option.getAttribute("value")
+                        for option in node.getElementsByTagName("option")
+                        if option.getAttribute("selected") == C.BOOL_TRUE
+                    ]
+                    ctrl = self.widget_factory.createListWidget(
+                        _xmlui_parent, _options, _selected, style
+                    )
+                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
+                elif type_ == "jids_list":
+                    style = []
+                    jids = [get_text(jid_) for jid_ in node.getElementsByTagName("jid")]
+                    ctrl = self.widget_factory.createJidsListWidget(
+                        _xmlui_parent, jids, style
+                    )
+                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
+                elif type_ == "button":
+                    callback_id = node.getAttribute("callback")
+                    ctrl = self.widget_factory.createButtonWidget(
+                        _xmlui_parent, value, self.on_button_press
+                    )
+                    ctrl._xmlui_param_id = (
+                        callback_id,
+                        [
+                            field.getAttribute("name")
+                            for field in node.getElementsByTagName("field_back")
+                        ],
+                    )
+                else:
+                    log.error(
+                        _("FIXME FIXME FIXME: widget type [%s] is not implemented")
+                        % type_
+                    )
+                    raise NotImplementedError(
+                        _("FIXME FIXME FIXME: type [%s] is not implemented") % type_
+                    )
+
+                if name:
+                    self.widgets[name] = ctrl
+
+                if self.type == "param" and type_ not in ("text", "button"):
+                    try:
+                        ctrl._xmlui_on_change(self.on_param_change)
+                        ctrl._param_category = self._current_category
+                    except (
+                        AttributeError,
+                        TypeError,
+                    ):  # XXX: TypeError is here because pyjamas raise a TypeError instead
+                        #      of an AttributeError
+                        if not isinstance(
+                            ctrl, (EmptyWidget, TextWidget, LabelWidget, JidWidget)
+                        ):
+                            log.warning(_("No change listener on [%s]") % ctrl)
+
+                elif type_ != "text":
+                    callback = node.getAttribute("internal_callback") or None
+                    if callback:
+                        fields = [
+                            field.getAttribute("name")
+                            for field in node.getElementsByTagName("internal_field")
+                        ]
+                        cb_data = self.get_internal_callback_data(callback, node)
+                        ctrl._xmlui_param_internal = (callback, fields, cb_data)
+                        if type_ == "button":
+                            ctrl._xmlui_on_click(self.on_change_internal)
+                        else:
+                            ctrl._xmlui_on_change(self.on_change_internal)
+
+                ctrl._xmlui_name = name
+                _xmlui_parent._xmlui_append(ctrl)
+                if CURRENT_LABEL in data and not isinstance(ctrl, LabelWidget):
+                    curr_label = data.pop(CURRENT_LABEL)
+                    if curr_label is not None:
+                        # this key is set in LabelContainer, when present
+                        # we can associate the label with the widget it is labelling
+                        curr_label._xmlui_for_name = name
+
+            else:
+                raise NotImplementedError(_("Unknown tag [%s]") % node.nodeName)
+
+    def construct_ui(self, parsed_dom, post_treat=None):
+        """Actually construct the UI
+
+        @param parsed_dom: main parsed dom
+        @param post_treat: frontend specific treatments to do once the UI is constructed
+        @return: constructed widget
+        """
+        top = parsed_dom.documentElement
+        self.type = top.getAttribute("type")
+        if top.nodeName != "sat_xmlui" or not self.type in [
+            "form",
+            "param",
+            "window",
+            "popup",
+        ]:
+            raise InvalidXMLUI
+
+        if self.type == "param":
+            self.param_changed = set()
+
+        self._parse_childs(self, parsed_dom.documentElement)
+
+        if post_treat is not None:
+            post_treat()
+
+    def _xmlui_set_param(self, name, value, category):
+        self.host.bridge.param_set(name, value, category, profile_key=self.profile)
+
+    ##EVENTS##
+
+    def on_param_change(self, ctrl):
+        """Called when type is param and a widget to save is modified
+
+        @param ctrl: widget modified
+        """
+        assert self.type == "param"
+        self.param_changed.add(ctrl)
+
+    def on_adv_list_select(self, ctrl):
+        data = {}
+        widgets = ctrl._xmlui_get_selected_widgets()
+        for wid in widgets:
+            try:
+                name = self.escape(wid._xmlui_name)
+                value = wid._xmlui_get_value()
+                data[name] = value
+            except (
+                AttributeError,
+                TypeError,
+            ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
+                pass
+        idx = ctrl._xmlui_get_selected_index()
+        if idx is not None:
+            data["index"] = idx
+        callback_id = ctrl._xmlui_callback_id
+        if callback_id is None:
+            log.info(_("No callback_id found"))
+            return
+        self._xmlui_launch_action(callback_id, data)
+
+    def on_button_press(self, button):
+        """Called when an XMLUI button is clicked
+
+        Launch the action associated to the button
+        @param button: the button clicked
+        """
+        callback_id, fields = button._xmlui_param_id
+        if not callback_id:  # the button is probably bound to an internal action
+            return
+        data = {}
+        for field in fields:
+            escaped = self.escape(field)
+            ctrl = self.ctrl_list[field]
+            if isinstance(ctrl["control"], ListWidget):
+                data[escaped] = "\t".join(ctrl["control"]._xmlui_get_selected_values())
+            else:
+                data[escaped] = ctrl["control"]._xmlui_get_value()
+        self._xmlui_launch_action(callback_id, data)
+
+    def on_change_internal(self, ctrl):
+        """Called when a widget that has been bound to an internal callback is changed.
+
+        This is used to perform some UI actions without communicating with the backend.
+        See sat.tools.xml_tools.Widget.set_internal_callback for more details.
+        @param ctrl: widget modified
+        """
+        action, fields, data = ctrl._xmlui_param_internal
+        if action not in ("copy", "move", "groups_of_contact"):
+            raise NotImplementedError(
+                _("FIXME: XMLUI internal action [%s] is not implemented") % action
+            )
+
+        def copy_move(source, target):
+            """Depending of 'action' value, copy or move from source to target."""
+            if isinstance(target, ListWidget):
+                if isinstance(source, ListWidget):
+                    values = source._xmlui_get_selected_values()
+                else:
+                    values = [source._xmlui_get_value()]
+                    if action == "move":
+                        source._xmlui_set_value("")
+                values = [value for value in values if value]
+                if values:
+                    target._xmlui_add_values(values, select=True)
+            else:
+                if isinstance(source, ListWidget):
+                    value = ", ".join(source._xmlui_get_selected_values())
+                else:
+                    value = source._xmlui_get_value()
+                    if action == "move":
+                        source._xmlui_set_value("")
+                target._xmlui_set_value(value)
+
+        def groups_of_contact(source, target):
+            """Select in target the groups of the contact which is selected in source."""
+            assert isinstance(source, ListWidget)
+            assert isinstance(target, ListWidget)
+            try:
+                contact_jid_s = source._xmlui_get_selected_values()[0]
+            except IndexError:
+                return
+            target._xmlui_select_values(data[contact_jid_s])
+            pass
+
+        source = None
+        for field in fields:
+            widget = self.ctrl_list[field]["control"]
+            if not source:
+                source = widget
+                continue
+            if action in ("copy", "move"):
+                copy_move(source, widget)
+            elif action == "groups_of_contact":
+                groups_of_contact(source, widget)
+            source = None
+
+    def get_internal_callback_data(self, action, node):
+        """Retrieve from node the data needed to perform given action.
+
+        @param action (string): a value from the one that can be passed to the
+            'callback' parameter of sat.tools.xml_tools.Widget.set_internal_callback
+        @param node (DOM Element): the node of the widget that triggers the callback
+        """
+        # TODO: it would be better to not have a specific way to retrieve
+        # data for each action, but instead to have a generic method to
+        # extract any kind of data structure from the 'internal_data' element.
+
+        try:  # data is stored in the first 'internal_data' element of the node
+            data_elts = node.getElementsByTagName("internal_data")[0].childNodes
+        except IndexError:
+            return None
+        data = {}
+        if (
+            action == "groups_of_contact"
+        ):  # return a dict(key: string, value: list[string])
+            for elt in data_elts:
+                jid_s = elt.getAttribute("name")
+                data[jid_s] = []
+                for value_elt in elt.childNodes:
+                    data[jid_s].append(value_elt.getAttribute("name"))
+        return data
+
+    def on_form_submitted(self, ignore=None):
+        """An XMLUI form has been submited
+
+        call the submit action associated with this form
+        """
+        selected_values = []
+        for ctrl_name in self.ctrl_list:
+            escaped = self.escape(ctrl_name)
+            ctrl = self.ctrl_list[ctrl_name]
+            if isinstance(ctrl["control"], ListWidget):
+                selected_values.append(
+                    (escaped, "\t".join(ctrl["control"]._xmlui_get_selected_values()))
+                )
+            else:
+                selected_values.append((escaped, ctrl["control"]._xmlui_get_value()))
+        data = dict(selected_values)
+        for key, value in self.hidden.items():
+            data[self.escape(key)] = value
+
+        if self.submit_id is not None:
+            self.submit(data)
+        else:
+            log.warning(
+                _("The form data is not sent back, the type is not managed properly")
+            )
+            self._xmlui_close()
+
+    def on_form_cancelled(self, *__):
+        """Called when a form is cancelled"""
+        log.debug(_("Cancelling form"))
+        if self.submit_id is not None:
+            data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE}
+            self.submit(data)
+        else:
+            log.warning(
+                _("The form data is not sent back, the type is not managed properly")
+            )
+        self._xmlui_close()
+
+    def on_save_params(self, ignore=None):
+        """Params are saved, we send them to backend
+
+        self.type must be param
+        """
+        assert self.type == "param"
+        for ctrl in self.param_changed:
+            if isinstance(ctrl, ListWidget):
+                value = "\t".join(ctrl._xmlui_get_selected_values())
+            else:
+                value = ctrl._xmlui_get_value()
+            param_name = ctrl._xmlui_name.split(C.SAT_PARAM_SEPARATOR)[1]
+            self._xmlui_set_param(param_name, value, ctrl._param_category)
+
+        self._xmlui_close()
+
+    def show(self, *args, **kwargs):
+        pass
+
+
+class AIOXMLUIPanel(XMLUIPanel):
+    """Asyncio compatible version of XMLUIPanel"""
+
+    async def on_form_submitted(self, ignore=None):
+        """An XMLUI form has been submited
+
+        call the submit action associated with this form
+        """
+        selected_values = []
+        for ctrl_name in self.ctrl_list:
+            escaped = self.escape(ctrl_name)
+            ctrl = self.ctrl_list[ctrl_name]
+            if isinstance(ctrl["control"], ListWidget):
+                selected_values.append(
+                    (escaped, "\t".join(ctrl["control"]._xmlui_get_selected_values()))
+                )
+            else:
+                selected_values.append((escaped, ctrl["control"]._xmlui_get_value()))
+        data = dict(selected_values)
+        for key, value in self.hidden.items():
+            data[self.escape(key)] = value
+
+        if self.submit_id is not None:
+            await self.submit(data)
+        else:
+            log.warning(
+                _("The form data is not sent back, the type is not managed properly")
+            )
+            self._xmlui_close()
+
+    async def on_form_cancelled(self, *__):
+        """Called when a form is cancelled"""
+        log.debug(_("Cancelling form"))
+        if self.submit_id is not None:
+            data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE}
+            await self.submit(data)
+        else:
+            log.warning(
+                _("The form data is not sent back, the type is not managed properly")
+            )
+        self._xmlui_close()
+
+    async def submit(self, data):
+        self._xmlui_close()
+        if self.submit_id is None:
+            raise ValueError("Can't submit is self.submit_id is not set")
+        if "session_id" in data:
+            raise ValueError(
+                "session_id must no be used in data, it is automaticaly filled with "
+                "self.session_id if present"
+            )
+        if self.session_id is not None:
+            data["session_id"] = self.session_id
+        await self._xmlui_launch_action(self.submit_id, data)
+
+    async def _xmlui_launch_action(self, action_id, data):
+        await self.host.action_launch(
+            action_id, data, callback=self.callback, profile=self.profile
+        )
+
+
+class XMLUIDialog(XMLUIBase):
+    dialog_factory = None
+
+    def __init__(
+        self,
+        host,
+        parsed_dom,
+        title=None,
+        flags=None,
+        callback=None,
+        ignore=None,
+        whitelist=None,
+        profile=C.PROF_KEY_NONE,
+    ):
+        super(XMLUIDialog, self).__init__(
+            host, parsed_dom, title=title, flags=flags, callback=callback, profile=profile
+        )
+        top = parsed_dom.documentElement
+        dlg_elt = self._get_child_node(top, "dialog")
+        if dlg_elt is None:
+            raise ValueError("Invalid XMLUI: no Dialog element found !")
+        dlg_type = dlg_elt.getAttribute("type") or C.XMLUI_DIALOG_MESSAGE
+        try:
+            mess_elt = self._get_child_node(dlg_elt, C.XMLUI_DATA_MESS)
+            message = get_text(mess_elt)
+        except (
+            TypeError,
+            AttributeError,
+        ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
+            message = ""
+        level = dlg_elt.getAttribute(C.XMLUI_DATA_LVL) or C.XMLUI_DATA_LVL_INFO
+
+        if dlg_type == C.XMLUI_DIALOG_MESSAGE:
+            self.dlg = self.dialog_factory.createMessageDialog(
+                self, self.xmlui_title, message, level
+            )
+        elif dlg_type == C.XMLUI_DIALOG_NOTE:
+            self.dlg = self.dialog_factory.createNoteDialog(
+                self, self.xmlui_title, message, level
+            )
+        elif dlg_type == C.XMLUI_DIALOG_CONFIRM:
+            try:
+                buttons_elt = self._get_child_node(dlg_elt, "buttons")
+                buttons_set = (
+                    buttons_elt.getAttribute("set") or C.XMLUI_DATA_BTNS_SET_DEFAULT
+                )
+            except (
+                TypeError,
+                AttributeError,
+            ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
+                buttons_set = C.XMLUI_DATA_BTNS_SET_DEFAULT
+            self.dlg = self.dialog_factory.createConfirmDialog(
+                self, self.xmlui_title, message, level, buttons_set
+            )
+        elif dlg_type == C.XMLUI_DIALOG_FILE:
+            try:
+                file_elt = self._get_child_node(dlg_elt, "file")
+                filetype = file_elt.getAttribute("type") or C.XMLUI_DATA_FILETYPE_DEFAULT
+            except (
+                TypeError,
+                AttributeError,
+            ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
+                filetype = C.XMLUI_DATA_FILETYPE_DEFAULT
+            self.dlg = self.dialog_factory.createFileDialog(
+                self, self.xmlui_title, message, level, filetype
+            )
+        else:
+            raise ValueError("Unknown dialog type [%s]" % dlg_type)
+
+    def show(self):
+        self.dlg._xmlui_show()
+
+    def _xmlui_close(self):
+        self.dlg._xmlui_close()
+
+
+def register_class(type_, class_):
+    """Register the class to use with the factory
+
+    @param type_: one of:
+        CLASS_PANEL: classical XMLUI interface
+        CLASS_DIALOG: XMLUI dialog
+    @param class_: the class to use to instanciate given type
+    """
+    # TODO: remove this method, as there are seme use cases where different XMLUI
+    #       classes can be used in the same frontend, so a global value is not good
+    assert type_ in (CLASS_PANEL, CLASS_DIALOG)
+    log.warning("register_class for XMLUI is deprecated, please use partial with "
+                "xmlui.create and class_map instead")
+    if type_ in _class_map:
+        log.debug(_("XMLUI class already registered for {type_}, ignoring").format(
+            type_=type_))
+        return
+
+    _class_map[type_] = class_
+
+
+def create(host, xml_data, title=None, flags=None, dom_parse=None, dom_free=None,
+           callback=None, ignore=None, whitelist=None, class_map=None,
+           profile=C.PROF_KEY_NONE):
+    """
+        @param dom_parse: methode equivalent to minidom.parseString (but which must manage unicode), or None to use default one
+        @param dom_free: method used to free the parsed DOM
+        @param ignore(list[unicode], None): name of widgets to ignore
+            widgets with name in this list and their label will be ignored
+        @param whitelist(list[unicode], None): name of widgets to keep
+            when not None, only widgets in this list and their label will be kept
+            mutually exclusive with ignore
+    """
+    if class_map is None:
+        class_map = _class_map
+    if dom_parse is None:
+        from xml.dom import minidom
+
+        dom_parse = lambda xml_data: minidom.parseString(xml_data.encode("utf-8"))
+        dom_free = lambda parsed_dom: parsed_dom.unlink()
+    else:
+        dom_parse = dom_parse
+        dom_free = dom_free or (lambda parsed_dom: None)
+    parsed_dom = dom_parse(xml_data)
+    top = parsed_dom.documentElement
+    ui_type = top.getAttribute("type")
+    try:
+        if ui_type != C.XMLUI_DIALOG:
+            cls = class_map[CLASS_PANEL]
+        else:
+            cls = class_map[CLASS_DIALOG]
+    except KeyError:
+        raise ClassNotRegistedError(
+            _("You must register classes with register_class before creating a XMLUI")
+        )
+
+    xmlui = cls(
+        host,
+        parsed_dom,
+        title=title,
+        flags=flags,
+        callback=callback,
+        ignore=ignore,
+        whitelist=whitelist,
+        profile=profile,
+    )
+    dom_free(parsed_dom)
+    return xmlui
--- a/sat_frontends/bridge/bridge_frontend.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,41 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT communication bridge
-# Copyright (C) 2009-2021 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/>.
-
-
-class BridgeException(Exception):
-    """An exception which has been raised from the backend and arrived to the frontend."""
-
-    def __init__(self, name, message="", condition=""):
-        """
-
-        @param name (str): full exception class name (with module)
-        @param message (str): error message
-        @param condition (str) : error condition
-        """
-        super().__init__()
-        self.fullname = str(name)
-        self.message = str(message)
-        self.condition = str(condition) if condition else ""
-        self.module, __, self.classname = str(self.fullname).rpartition(".")
-
-    def __str__(self):
-        return self.classname + (f": {self.message}" if self.message else "")
-
-    def __eq__(self, other):
-        return self.classname == other
--- a/sat_frontends/bridge/dbus_bridge.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1512 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT communication bridge
-# Copyright (C) 2009-2021 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/>.
-
-import asyncio
-import dbus
-import ast
-from libervia.backend.core.i18n import _
-from libervia.backend.tools import config
-from libervia.backend.core.log import getLogger
-from libervia.backend.core.exceptions import BridgeExceptionNoService, BridgeInitError
-from dbus.mainloop.glib import DBusGMainLoop
-from .bridge_frontend import BridgeException
-
-
-DBusGMainLoop(set_as_default=True)
-log = getLogger(__name__)
-
-
-# Interface prefix
-const_INT_PREFIX = config.config_get(
-    config.parse_main_conf(),
-    "",
-    "bridge_dbus_int_prefix",
-    "org.libervia.Libervia")
-const_ERROR_PREFIX = const_INT_PREFIX + ".error"
-const_OBJ_PATH = '/org/libervia/Libervia/bridge'
-const_CORE_SUFFIX = ".core"
-const_PLUGIN_SUFFIX = ".plugin"
-const_TIMEOUT = 120
-
-
-def dbus_to_bridge_exception(dbus_e):
-    """Convert a DBusException to a BridgeException.
-
-    @param dbus_e (DBusException)
-    @return: BridgeException
-    """
-    full_name = dbus_e.get_dbus_name()
-    if full_name.startswith(const_ERROR_PREFIX):
-        name = dbus_e.get_dbus_name()[len(const_ERROR_PREFIX) + 1:]
-    else:
-        name = full_name
-    # XXX: dbus_e.args doesn't contain the original DBusException args, but we
-    # receive its serialized form in dbus_e.args[0]. From that we can rebuild
-    # the original arguments list thanks to ast.literal_eval (secure eval).
-    message = dbus_e.get_dbus_message()  # similar to dbus_e.args[0]
-    try:
-        message, condition = ast.literal_eval(message)
-    except (SyntaxError, ValueError, TypeError):
-        condition = ''
-    return BridgeException(name, message, condition)
-
-
-class bridge:
-
-    def bridge_connect(self, callback, errback):
-        try:
-            self.sessions_bus = dbus.SessionBus()
-            self.db_object = self.sessions_bus.get_object(const_INT_PREFIX,
-                                                          const_OBJ_PATH)
-            self.db_core_iface = dbus.Interface(self.db_object,
-                                                dbus_interface=const_INT_PREFIX + const_CORE_SUFFIX)
-            self.db_plugin_iface = dbus.Interface(self.db_object,
-                                                  dbus_interface=const_INT_PREFIX + const_PLUGIN_SUFFIX)
-        except dbus.exceptions.DBusException as e:
-            if e._dbus_error_name in ('org.freedesktop.DBus.Error.ServiceUnknown',
-                                      'org.freedesktop.DBus.Error.Spawn.ExecFailed'):
-                errback(BridgeExceptionNoService())
-            elif e._dbus_error_name == 'org.freedesktop.DBus.Error.NotSupported':
-                log.error(_("D-Bus is not launched, please see README to see instructions on how to launch it"))
-                errback(BridgeInitError)
-            else:
-                errback(e)
-        else:
-            callback()
-        #props = self.db_core_iface.getProperties()
-
-    def register_signal(self, functionName, handler, iface="core"):
-        if iface == "core":
-            self.db_core_iface.connect_to_signal(functionName, handler)
-        elif iface == "plugin":
-            self.db_plugin_iface.connect_to_signal(functionName, handler)
-        else:
-            log.error(_('Unknown interface'))
-
-    def __getattribute__(self, name):
-        """ usual __getattribute__ if the method exists, else try to find a plugin method """
-        try:
-            return object.__getattribute__(self, name)
-        except AttributeError:
-            # The attribute is not found, we try the plugin proxy to find the requested method
-
-            def get_plugin_method(*args, **kwargs):
-                # We first check if we have an async call. We detect this in two ways:
-                #   - if we have the 'callback' and 'errback' keyword arguments
-                #   - or if the last two arguments are callable
-
-                async_ = False
-                args = list(args)
-
-                if kwargs:
-                    if 'callback' in kwargs:
-                        async_ = True
-                        _callback = kwargs.pop('callback')
-                        _errback = kwargs.pop('errback', lambda failure: log.error(str(failure)))
-                    try:
-                        args.append(kwargs.pop('profile'))
-                    except KeyError:
-                        try:
-                            args.append(kwargs.pop('profile_key'))
-                        except KeyError:
-                            pass
-                    # at this point, kwargs should be empty
-                    if kwargs:
-                        log.warning("unexpected keyword arguments, they will be ignored: {}".format(kwargs))
-                elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]):
-                    async_ = True
-                    _errback = args.pop()
-                    _callback = args.pop()
-
-                method = getattr(self.db_plugin_iface, name)
-
-                if async_:
-                    kwargs['timeout'] = const_TIMEOUT
-                    kwargs['reply_handler'] = _callback
-                    kwargs['error_handler'] = lambda err: _errback(dbus_to_bridge_exception(err))
-
-                try:
-                    return method(*args, **kwargs)
-                except ValueError as e:
-                    if e.args[0].startswith("Unable to guess signature"):
-                        # XXX: if frontend is started too soon after backend, the
-                        #   inspection misses methods (notably plugin dynamically added
-                        #   methods). The following hack works around that by redoing the
-                        #   cache of introspected methods signatures.
-                        log.debug("using hack to work around inspection issue")
-                        proxy = self.db_plugin_iface.proxy_object
-                        IN_PROGRESS = proxy.INTROSPECT_STATE_INTROSPECT_IN_PROGRESS
-                        proxy._introspect_state = IN_PROGRESS
-                        proxy._Introspect()
-                        return self.db_plugin_iface.get_dbus_method(name)(*args, **kwargs)
-                    raise e
-
-            return get_plugin_method
-
-    def action_launch(self, callback_id, data, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return str(self.db_core_iface.action_launch(callback_id, data, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
-
-    def actions_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.actions_get(profile_key, **kwargs)
-
-    def config_get(self, section, name, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.config_get(section, name, **kwargs))
-
-    def connect(self, profile_key="@DEFAULT@", password='', options={}, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.connect(profile_key, password, options, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def contact_add(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.contact_add(entity_jid, profile_key, **kwargs)
-
-    def contact_del(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.contact_del(entity_jid, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def contact_get(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.contact_get(arg_0, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.contact_update(entity_jid, name, groups, profile_key, **kwargs)
-
-    def contacts_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.contacts_get(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def contacts_get_from_group(self, group, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.contacts_get_from_group(group, profile_key, **kwargs)
-
-    def devices_infos_get(self, bare_jid, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return str(self.db_core_iface.devices_infos_get(bare_jid, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
-
-    def disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.disco_find_by_features(namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.disco_infos(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.disco_items(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def disconnect(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.disconnect(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def encryption_namespace_get(self, arg_0, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.encryption_namespace_get(arg_0, **kwargs))
-
-    def encryption_plugins_get(self, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.encryption_plugins_get(**kwargs))
-
-    def encryption_trust_ui_get(self, to_jid, namespace, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return str(self.db_core_iface.encryption_trust_ui_get(to_jid, namespace, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
-
-    def entities_data_get(self, jids, keys, profile, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.entities_data_get(jids, keys, profile, **kwargs)
-
-    def entity_data_get(self, jid, keys, profile, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.entity_data_get(jid, keys, profile, **kwargs)
-
-    def features_get(self, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.features_get(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.history_get(from_jid, to_jid, limit, between, filters, profile, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def image_check(self, arg_0, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.image_check(arg_0, **kwargs))
-
-    def image_convert(self, source, dest, arg_2, extra, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return str(self.db_core_iface.image_convert(source, dest, arg_2, extra, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
-
-    def image_generate_preview(self, image_path, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return str(self.db_core_iface.image_generate_preview(image_path, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
-
-    def image_resize(self, image_path, width, height, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return str(self.db_core_iface.image_resize(image_path, width, height, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
-
-    def is_connected(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.is_connected(profile_key, **kwargs)
-
-    def main_resource_get(self, contact_jid, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.main_resource_get(contact_jid, profile_key, **kwargs))
-
-    def menu_help_get(self, menu_id, language, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.menu_help_get(menu_id, language, **kwargs))
-
-    def menu_launch(self, menu_type, path, data, security_limit, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.menu_launch(menu_type, path, data, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def menus_get(self, language, security_limit, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.menus_get(language, security_limit, **kwargs)
-
-    def message_encryption_get(self, to_jid, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.message_encryption_get(to_jid, profile_key, **kwargs))
-
-    def message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.message_encryption_start(to_jid, namespace, replace, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def message_encryption_stop(self, to_jid, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.message_encryption_stop(to_jid, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.message_send(to_jid, message, subject, mess_type, extra, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def namespaces_get(self, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.namespaces_get(**kwargs)
-
-    def param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.param_get_a(name, category, attribute, profile_key, **kwargs))
-
-    def param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return str(self.db_core_iface.param_get_a_async(name, category, attribute, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
-
-    def param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.param_set(name, value, category, security_limit, profile_key, **kwargs)
-
-    def param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return str(self.db_core_iface.param_ui_get(security_limit, app, extra, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
-
-    def params_categories_get(self, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.params_categories_get(**kwargs)
-
-    def params_register_app(self, xml, security_limit=-1, app='', callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.params_register_app(xml, security_limit, app, **kwargs)
-
-    def params_template_load(self, filename, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.params_template_load(filename, **kwargs)
-
-    def params_template_save(self, filename, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.params_template_save(filename, **kwargs)
-
-    def params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.params_values_from_category_get_async(category, security_limit, app, extra, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.presence_set(to_jid, show, statuses, profile_key, **kwargs)
-
-    def presence_statuses_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.presence_statuses_get(profile_key, **kwargs)
-
-    def private_data_delete(self, namespace, key, arg_2, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.private_data_delete(namespace, key, arg_2, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def private_data_get(self, namespace, key, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return str(self.db_core_iface.private_data_get(namespace, key, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler))
-
-    def private_data_set(self, namespace, key, data, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.private_data_set(namespace, key, data, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def profile_create(self, profile, password='', component='', callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.profile_create(profile, password, component, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def profile_delete_async(self, profile, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.profile_delete_async(profile, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def profile_is_session_started(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.profile_is_session_started(profile_key, **kwargs)
-
-    def profile_name_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.profile_name_get(profile_key, **kwargs))
-
-    def profile_set_default(self, profile, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.profile_set_default(profile, **kwargs)
-
-    def profile_start_session(self, password='', profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.profile_start_session(password, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def profiles_list_get(self, clients=True, components=False, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.profiles_list_get(clients, components, **kwargs)
-
-    def progress_get(self, id, profile, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.progress_get(id, profile, **kwargs)
-
-    def progress_get_all(self, profile, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.progress_get_all(profile, **kwargs)
-
-    def progress_get_all_metadata(self, profile, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.progress_get_all_metadata(profile, **kwargs)
-
-    def ready_get(self, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.ready_get(timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def roster_resync(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.roster_resync(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def session_infos_get(self, profile_key, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        return self.db_core_iface.session_infos_get(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)
-
-    def sub_waiting_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.sub_waiting_get(profile_key, **kwargs)
-
-    def subscription(self, sub_type, entity, profile_key="@DEFAULT@", callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return self.db_core_iface.subscription(sub_type, entity, profile_key, **kwargs)
-
-    def version_get(self, callback=None, errback=None):
-        if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        return str(self.db_core_iface.version_get(**kwargs))
-
-
-class AIOBridge(bridge):
-
-    def register_signal(self, functionName, handler, iface="core"):
-        loop = asyncio.get_running_loop()
-        async_handler = lambda *args: asyncio.run_coroutine_threadsafe(handler(*args), loop)
-        return super().register_signal(functionName, async_handler, iface)
-
-    def __getattribute__(self, name):
-        """ usual __getattribute__ if the method exists, else try to find a plugin method """
-        try:
-            return object.__getattribute__(self, name)
-        except AttributeError:
-            # The attribute is not found, we try the plugin proxy to find the requested method
-            def get_plugin_method(*args, **kwargs):
-                loop = asyncio.get_running_loop()
-                fut = loop.create_future()
-                method = getattr(self.db_plugin_iface, name)
-                reply_handler = lambda ret=None: loop.call_soon_threadsafe(
-                    fut.set_result, ret)
-                error_handler = lambda err: loop.call_soon_threadsafe(
-                    fut.set_exception, dbus_to_bridge_exception(err))
-                try:
-                    method(
-                        *args,
-                        **kwargs,
-                        timeout=const_TIMEOUT,
-                        reply_handler=reply_handler,
-                        error_handler=error_handler
-                    )
-                except ValueError as e:
-                    if e.args[0].startswith("Unable to guess signature"):
-                        # same hack as for bridge.__getattribute__
-                        log.warning("using hack to work around inspection issue")
-                        proxy = self.db_plugin_iface.proxy_object
-                        IN_PROGRESS = proxy.INTROSPECT_STATE_INTROSPECT_IN_PROGRESS
-                        proxy._introspect_state = IN_PROGRESS
-                        proxy._Introspect()
-                        self.db_plugin_iface.get_dbus_method(name)(
-                            *args,
-                            **kwargs,
-                            timeout=const_TIMEOUT,
-                            reply_handler=reply_handler,
-                            error_handler=error_handler
-                        )
-
-                    else:
-                        raise e
-                return fut
-
-            return get_plugin_method
-
-    def bridge_connect(self):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        super().bridge_connect(
-            callback=lambda: loop.call_soon_threadsafe(fut.set_result, None),
-            errback=lambda e: loop.call_soon_threadsafe(fut.set_exception, e)
-        )
-        return fut
-
-    def action_launch(self, callback_id, data, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.action_launch(callback_id, data, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def actions_get(self, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.actions_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def config_get(self, section, name):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.config_get(section, name, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def connect(self, profile_key="@DEFAULT@", password='', options={}):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.connect(profile_key, password, options, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def contact_add(self, entity_jid, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.contact_add(entity_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def contact_del(self, entity_jid, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.contact_del(entity_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def contact_get(self, arg_0, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.contact_get(arg_0, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.contact_update(entity_jid, name, groups, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def contacts_get(self, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.contacts_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def contacts_get_from_group(self, group, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.contacts_get_from_group(group, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def devices_infos_get(self, bare_jid, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.devices_infos_get(bare_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.disco_find_by_features(namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.disco_infos(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.disco_items(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def disconnect(self, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.disconnect(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def encryption_namespace_get(self, arg_0):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.encryption_namespace_get(arg_0, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def encryption_plugins_get(self):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.encryption_plugins_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def encryption_trust_ui_get(self, to_jid, namespace, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.encryption_trust_ui_get(to_jid, namespace, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def entities_data_get(self, jids, keys, profile):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.entities_data_get(jids, keys, profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def entity_data_get(self, jid, keys, profile):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.entity_data_get(jid, keys, profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def features_get(self, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.features_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.history_get(from_jid, to_jid, limit, between, filters, profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def image_check(self, arg_0):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.image_check(arg_0, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def image_convert(self, source, dest, arg_2, extra):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.image_convert(source, dest, arg_2, extra, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def image_generate_preview(self, image_path, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.image_generate_preview(image_path, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def image_resize(self, image_path, width, height):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.image_resize(image_path, width, height, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def is_connected(self, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.is_connected(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def main_resource_get(self, contact_jid, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.main_resource_get(contact_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def menu_help_get(self, menu_id, language):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.menu_help_get(menu_id, language, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def menu_launch(self, menu_type, path, data, security_limit, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.menu_launch(menu_type, path, data, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def menus_get(self, language, security_limit):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.menus_get(language, security_limit, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def message_encryption_get(self, to_jid, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.message_encryption_get(to_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.message_encryption_start(to_jid, namespace, replace, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def message_encryption_stop(self, to_jid, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.message_encryption_stop(to_jid, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.message_send(to_jid, message, subject, mess_type, extra, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def namespaces_get(self):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.namespaces_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.param_get_a(name, category, attribute, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.param_get_a_async(name, category, attribute, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.param_set(name, value, category, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.param_ui_get(security_limit, app, extra, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def params_categories_get(self):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.params_categories_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def params_register_app(self, xml, security_limit=-1, app=''):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.params_register_app(xml, security_limit, app, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def params_template_load(self, filename):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.params_template_load(filename, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def params_template_save(self, filename):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.params_template_save(filename, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.params_values_from_category_get_async(category, security_limit, app, extra, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.presence_set(to_jid, show, statuses, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def presence_statuses_get(self, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.presence_statuses_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def private_data_delete(self, namespace, key, arg_2):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.private_data_delete(namespace, key, arg_2, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def private_data_get(self, namespace, key, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.private_data_get(namespace, key, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def private_data_set(self, namespace, key, data, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.private_data_set(namespace, key, data, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def profile_create(self, profile, password='', component=''):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.profile_create(profile, password, component, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def profile_delete_async(self, profile):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.profile_delete_async(profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def profile_is_session_started(self, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.profile_is_session_started(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def profile_name_get(self, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.profile_name_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def profile_set_default(self, profile):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.profile_set_default(profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def profile_start_session(self, password='', profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.profile_start_session(password, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def profiles_list_get(self, clients=True, components=False):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.profiles_list_get(clients, components, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def progress_get(self, id, profile):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.progress_get(id, profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def progress_get_all(self, profile):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.progress_get_all(profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def progress_get_all_metadata(self, profile):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.progress_get_all_metadata(profile, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def ready_get(self):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.ready_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def roster_resync(self, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.roster_resync(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def session_infos_get(self, profile_key):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.session_infos_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def sub_waiting_get(self, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.sub_waiting_get(profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def subscription(self, sub_type, entity, profile_key="@DEFAULT@"):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.subscription(sub_type, entity, profile_key, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
-
-    def version_get(self):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_core_iface.version_get(timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        return fut
--- a/sat_frontends/bridge/pb.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1120 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT communication bridge
-# Copyright (C) 2009-2021 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/>.
-
-import asyncio
-from logging import getLogger
-from functools import partial
-from pathlib import Path
-from twisted.spread import pb
-from twisted.internet import reactor, defer
-from twisted.internet.error import ConnectionRefusedError, ConnectError
-from libervia.backend.core import exceptions
-from libervia.backend.tools import config
-from sat_frontends.bridge.bridge_frontend import BridgeException
-
-log = getLogger(__name__)
-
-
-class SignalsHandler(pb.Referenceable):
-    def __getattr__(self, name):
-        if name.startswith("remote_"):
-            log.debug("calling an unregistered signal: {name}".format(name=name[7:]))
-            return lambda *args, **kwargs: None
-
-        else:
-            raise AttributeError(name)
-
-    def register_signal(self, name, handler, iface="core"):
-        log.debug("registering signal {name}".format(name=name))
-        method_name = "remote_" + name
-        try:
-            self.__getattribute__(method_name)
-        except AttributeError:
-            pass
-        else:
-            raise exceptions.InternalError(
-                "{name} signal handler has been registered twice".format(
-                    name=method_name
-                )
-            )
-        setattr(self, method_name, handler)
-
-
-class bridge(object):
-
-    def __init__(self):
-        self.signals_handler = SignalsHandler()
-
-    def __getattr__(self, name):
-        return partial(self.call, name)
-
-    def _generic_errback(self, err):
-        log.error(f"bridge error: {err}")
-
-    def _errback(self, failure_, ori_errback):
-        """Convert Failure to BridgeException"""
-        ori_errback(
-            BridgeException(
-                name=failure_.type.decode('utf-8'),
-                message=str(failure_.value)
-            )
-        )
-
-    def remote_callback(self, result, callback):
-        """call callback with argument or None
-
-        if result is not None not argument is used,
-        else result is used as argument
-        @param result: remote call result
-        @param callback(callable): method to call on result
-        """
-        if result is None:
-            callback()
-        else:
-            callback(result)
-
-    def call(self, name, *args, **kwargs):
-        """call a remote method
-
-        @param name(str): name of the bridge method
-        @param args(list): arguments
-            may contain callback and errback as last 2 items
-        @param kwargs(dict): keyword arguments
-            may contain callback and errback
-        """
-        callback = errback = None
-        if kwargs:
-            try:
-                callback = kwargs.pop("callback")
-            except KeyError:
-                pass
-            try:
-                errback = kwargs.pop("errback")
-            except KeyError:
-                pass
-        elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]):
-            errback = args.pop()
-            callback = args.pop()
-        d = self.root.callRemote(name, *args, **kwargs)
-        if callback is not None:
-            d.addCallback(self.remote_callback, callback)
-        if errback is not None:
-            d.addErrback(errback)
-
-    def _init_bridge_eb(self, failure_):
-        log.error("Can't init bridge: {msg}".format(msg=failure_))
-        return failure_
-
-    def _set_root(self, root):
-        """set remote root object
-
-        bridge will then be initialised
-        """
-        self.root = root
-        d = root.callRemote("initBridge", self.signals_handler)
-        d.addErrback(self._init_bridge_eb)
-        return d
-
-    def get_root_object_eb(self, failure_):
-        """Call errback with appropriate bridge error"""
-        if failure_.check(ConnectionRefusedError, ConnectError):
-            raise exceptions.BridgeExceptionNoService
-        else:
-            raise failure_
-
-    def bridge_connect(self, callback, errback):
-        factory = pb.PBClientFactory()
-        conf = config.parse_main_conf()
-        get_conf = partial(config.get_conf, conf, "bridge_pb", "")
-        conn_type = get_conf("connection_type", "unix_socket")
-        if conn_type == "unix_socket":
-            local_dir = Path(config.config_get(conf, "", "local_dir")).resolve()
-            socket_path = local_dir / "bridge_pb"
-            reactor.connectUNIX(str(socket_path), factory)
-        elif conn_type == "socket":
-            host = get_conf("host", "localhost")
-            port = int(get_conf("port", 8789))
-            reactor.connectTCP(host, port, factory)
-        else:
-            raise ValueError(f"Unknown pb connection type: {conn_type!r}")
-        d = factory.getRootObject()
-        d.addCallback(self._set_root)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        d.addErrback(self.get_root_object_eb)
-        if errback is not None:
-            d.addErrback(lambda failure_: errback(failure_.value))
-        return d
-
-    def register_signal(self, functionName, handler, iface="core"):
-        self.signals_handler.register_signal(functionName, handler, iface)
-
-
-    def action_launch(self, callback_id, data, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("action_launch", callback_id, data, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def actions_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("actions_get", profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def config_get(self, section, name, callback=None, errback=None):
-        d = self.root.callRemote("config_get", section, name)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def connect(self, profile_key="@DEFAULT@", password='', options={}, callback=None, errback=None):
-        d = self.root.callRemote("connect", profile_key, password, options)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def contact_add(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("contact_add", entity_jid, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def contact_del(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("contact_del", entity_jid, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def contact_get(self, arg_0, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("contact_get", arg_0, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("contact_update", entity_jid, name, groups, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def contacts_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("contacts_get", profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def contacts_get_from_group(self, group, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("contacts_get_from_group", group, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def devices_infos_get(self, bare_jid, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("devices_infos_get", bare_jid, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("disco_find_by_features", namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("disco_infos", entity_jid, node, use_cache, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("disco_items", entity_jid, node, use_cache, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def disconnect(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("disconnect", profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def encryption_namespace_get(self, arg_0, callback=None, errback=None):
-        d = self.root.callRemote("encryption_namespace_get", arg_0)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def encryption_plugins_get(self, callback=None, errback=None):
-        d = self.root.callRemote("encryption_plugins_get")
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def encryption_trust_ui_get(self, to_jid, namespace, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("encryption_trust_ui_get", to_jid, namespace, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def entities_data_get(self, jids, keys, profile, callback=None, errback=None):
-        d = self.root.callRemote("entities_data_get", jids, keys, profile)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def entity_data_get(self, jid, keys, profile, callback=None, errback=None):
-        d = self.root.callRemote("entity_data_get", jid, keys, profile)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def features_get(self, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("features_get", profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@", callback=None, errback=None):
-        d = self.root.callRemote("history_get", from_jid, to_jid, limit, between, filters, profile)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def image_check(self, arg_0, callback=None, errback=None):
-        d = self.root.callRemote("image_check", arg_0)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def image_convert(self, source, dest, arg_2, extra, callback=None, errback=None):
-        d = self.root.callRemote("image_convert", source, dest, arg_2, extra)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def image_generate_preview(self, image_path, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("image_generate_preview", image_path, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def image_resize(self, image_path, width, height, callback=None, errback=None):
-        d = self.root.callRemote("image_resize", image_path, width, height)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def is_connected(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("is_connected", profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def main_resource_get(self, contact_jid, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("main_resource_get", contact_jid, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def menu_help_get(self, menu_id, language, callback=None, errback=None):
-        d = self.root.callRemote("menu_help_get", menu_id, language)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def menu_launch(self, menu_type, path, data, security_limit, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("menu_launch", menu_type, path, data, security_limit, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def menus_get(self, language, security_limit, callback=None, errback=None):
-        d = self.root.callRemote("menus_get", language, security_limit)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def message_encryption_get(self, to_jid, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("message_encryption_get", to_jid, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@", callback=None, errback=None):
-        d = self.root.callRemote("message_encryption_start", to_jid, namespace, replace, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def message_encryption_stop(self, to_jid, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("message_encryption_stop", to_jid, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None):
-        d = self.root.callRemote("message_send", to_jid, message, subject, mess_type, extra, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def namespaces_get(self, callback=None, errback=None):
-        d = self.root.callRemote("namespaces_get")
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("param_get_a", name, category, attribute, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("param_get_a_async", name, category, attribute, security_limit, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("param_set", name, value, category, security_limit, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("param_ui_get", security_limit, app, extra, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def params_categories_get(self, callback=None, errback=None):
-        d = self.root.callRemote("params_categories_get")
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def params_register_app(self, xml, security_limit=-1, app='', callback=None, errback=None):
-        d = self.root.callRemote("params_register_app", xml, security_limit, app)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def params_template_load(self, filename, callback=None, errback=None):
-        d = self.root.callRemote("params_template_load", filename)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def params_template_save(self, filename, callback=None, errback=None):
-        d = self.root.callRemote("params_template_save", filename)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("params_values_from_category_get_async", category, security_limit, app, extra, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("presence_set", to_jid, show, statuses, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def presence_statuses_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("presence_statuses_get", profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def private_data_delete(self, namespace, key, arg_2, callback=None, errback=None):
-        d = self.root.callRemote("private_data_delete", namespace, key, arg_2)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def private_data_get(self, namespace, key, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("private_data_get", namespace, key, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def private_data_set(self, namespace, key, data, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("private_data_set", namespace, key, data, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def profile_create(self, profile, password='', component='', callback=None, errback=None):
-        d = self.root.callRemote("profile_create", profile, password, component)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def profile_delete_async(self, profile, callback=None, errback=None):
-        d = self.root.callRemote("profile_delete_async", profile)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def profile_is_session_started(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("profile_is_session_started", profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def profile_name_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("profile_name_get", profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def profile_set_default(self, profile, callback=None, errback=None):
-        d = self.root.callRemote("profile_set_default", profile)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def profile_start_session(self, password='', profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("profile_start_session", password, profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def profiles_list_get(self, clients=True, components=False, callback=None, errback=None):
-        d = self.root.callRemote("profiles_list_get", clients, components)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def progress_get(self, id, profile, callback=None, errback=None):
-        d = self.root.callRemote("progress_get", id, profile)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def progress_get_all(self, profile, callback=None, errback=None):
-        d = self.root.callRemote("progress_get_all", profile)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def progress_get_all_metadata(self, profile, callback=None, errback=None):
-        d = self.root.callRemote("progress_get_all_metadata", profile)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def ready_get(self, callback=None, errback=None):
-        d = self.root.callRemote("ready_get")
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def roster_resync(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("roster_resync", profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def session_infos_get(self, profile_key, callback=None, errback=None):
-        d = self.root.callRemote("session_infos_get", profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def sub_waiting_get(self, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("sub_waiting_get", profile_key)
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def subscription(self, sub_type, entity, profile_key="@DEFAULT@", callback=None, errback=None):
-        d = self.root.callRemote("subscription", sub_type, entity, profile_key)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-    def version_get(self, callback=None, errback=None):
-        d = self.root.callRemote("version_get")
-        if callback is not None:
-            d.addCallback(callback)
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)
-
-
-class AIOSignalsHandler(SignalsHandler):
-
-    def register_signal(self, name, handler, iface="core"):
-        async_handler = lambda *args, **kwargs: defer.Deferred.fromFuture(
-            asyncio.ensure_future(handler(*args, **kwargs)))
-        return super().register_signal(name, async_handler, iface)
-
-
-class AIOBridge(bridge):
-
-    def __init__(self):
-        self.signals_handler = AIOSignalsHandler()
-
-    def _errback(self, failure_):
-        """Convert Failure to BridgeException"""
-        raise BridgeException(
-            name=failure_.type.decode('utf-8'),
-            message=str(failure_.value)
-            )
-
-    def call(self, name, *args, **kwargs):
-        d = self.root.callRemote(name, *args, *kwargs)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    async def bridge_connect(self):
-        d = super().bridge_connect(callback=None, errback=None)
-        return await d.asFuture(asyncio.get_event_loop())
-
-    def action_launch(self, callback_id, data, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("action_launch", callback_id, data, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def actions_get(self, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("actions_get", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def config_get(self, section, name):
-        d = self.root.callRemote("config_get", section, name)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def connect(self, profile_key="@DEFAULT@", password='', options={}):
-        d = self.root.callRemote("connect", profile_key, password, options)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def contact_add(self, entity_jid, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("contact_add", entity_jid, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def contact_del(self, entity_jid, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("contact_del", entity_jid, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def contact_get(self, arg_0, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("contact_get", arg_0, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("contact_update", entity_jid, name, groups, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def contacts_get(self, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("contacts_get", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def contacts_get_from_group(self, group, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("contacts_get_from_group", group, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def devices_infos_get(self, bare_jid, profile_key):
-        d = self.root.callRemote("devices_infos_get", bare_jid, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("disco_find_by_features", namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("disco_infos", entity_jid, node, use_cache, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("disco_items", entity_jid, node, use_cache, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def disconnect(self, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("disconnect", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def encryption_namespace_get(self, arg_0):
-        d = self.root.callRemote("encryption_namespace_get", arg_0)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def encryption_plugins_get(self):
-        d = self.root.callRemote("encryption_plugins_get")
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def encryption_trust_ui_get(self, to_jid, namespace, profile_key):
-        d = self.root.callRemote("encryption_trust_ui_get", to_jid, namespace, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def entities_data_get(self, jids, keys, profile):
-        d = self.root.callRemote("entities_data_get", jids, keys, profile)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def entity_data_get(self, jid, keys, profile):
-        d = self.root.callRemote("entity_data_get", jid, keys, profile)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def features_get(self, profile_key):
-        d = self.root.callRemote("features_get", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@"):
-        d = self.root.callRemote("history_get", from_jid, to_jid, limit, between, filters, profile)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def image_check(self, arg_0):
-        d = self.root.callRemote("image_check", arg_0)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def image_convert(self, source, dest, arg_2, extra):
-        d = self.root.callRemote("image_convert", source, dest, arg_2, extra)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def image_generate_preview(self, image_path, profile_key):
-        d = self.root.callRemote("image_generate_preview", image_path, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def image_resize(self, image_path, width, height):
-        d = self.root.callRemote("image_resize", image_path, width, height)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def is_connected(self, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("is_connected", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def main_resource_get(self, contact_jid, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("main_resource_get", contact_jid, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def menu_help_get(self, menu_id, language):
-        d = self.root.callRemote("menu_help_get", menu_id, language)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def menu_launch(self, menu_type, path, data, security_limit, profile_key):
-        d = self.root.callRemote("menu_launch", menu_type, path, data, security_limit, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def menus_get(self, language, security_limit):
-        d = self.root.callRemote("menus_get", language, security_limit)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def message_encryption_get(self, to_jid, profile_key):
-        d = self.root.callRemote("message_encryption_get", to_jid, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@"):
-        d = self.root.callRemote("message_encryption_start", to_jid, namespace, replace, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def message_encryption_stop(self, to_jid, profile_key):
-        d = self.root.callRemote("message_encryption_stop", to_jid, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@"):
-        d = self.root.callRemote("message_send", to_jid, message, subject, mess_type, extra, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def namespaces_get(self):
-        d = self.root.callRemote("namespaces_get")
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@"):
-        d = self.root.callRemote("param_get_a", name, category, attribute, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("param_get_a_async", name, category, attribute, security_limit, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("param_set", name, value, category, security_limit, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@"):
-        d = self.root.callRemote("param_ui_get", security_limit, app, extra, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def params_categories_get(self):
-        d = self.root.callRemote("params_categories_get")
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def params_register_app(self, xml, security_limit=-1, app=''):
-        d = self.root.callRemote("params_register_app", xml, security_limit, app)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def params_template_load(self, filename):
-        d = self.root.callRemote("params_template_load", filename)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def params_template_save(self, filename):
-        d = self.root.callRemote("params_template_save", filename)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@"):
-        d = self.root.callRemote("params_values_from_category_get_async", category, security_limit, app, extra, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("presence_set", to_jid, show, statuses, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def presence_statuses_get(self, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("presence_statuses_get", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def private_data_delete(self, namespace, key, arg_2):
-        d = self.root.callRemote("private_data_delete", namespace, key, arg_2)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def private_data_get(self, namespace, key, profile_key):
-        d = self.root.callRemote("private_data_get", namespace, key, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def private_data_set(self, namespace, key, data, profile_key):
-        d = self.root.callRemote("private_data_set", namespace, key, data, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def profile_create(self, profile, password='', component=''):
-        d = self.root.callRemote("profile_create", profile, password, component)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def profile_delete_async(self, profile):
-        d = self.root.callRemote("profile_delete_async", profile)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def profile_is_session_started(self, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("profile_is_session_started", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def profile_name_get(self, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("profile_name_get", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def profile_set_default(self, profile):
-        d = self.root.callRemote("profile_set_default", profile)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def profile_start_session(self, password='', profile_key="@DEFAULT@"):
-        d = self.root.callRemote("profile_start_session", password, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def profiles_list_get(self, clients=True, components=False):
-        d = self.root.callRemote("profiles_list_get", clients, components)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def progress_get(self, id, profile):
-        d = self.root.callRemote("progress_get", id, profile)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def progress_get_all(self, profile):
-        d = self.root.callRemote("progress_get_all", profile)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def progress_get_all_metadata(self, profile):
-        d = self.root.callRemote("progress_get_all_metadata", profile)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def ready_get(self):
-        d = self.root.callRemote("ready_get")
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def roster_resync(self, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("roster_resync", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def session_infos_get(self, profile_key):
-        d = self.root.callRemote("session_infos_get", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def sub_waiting_get(self, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("sub_waiting_get", profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def subscription(self, sub_type, entity, profile_key="@DEFAULT@"):
-        d = self.root.callRemote("subscription", sub_type, entity, profile_key)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    def version_get(self):
-        d = self.root.callRemote("version_get")
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
--- a/sat_frontends/jp/arg_tools.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-
-
-def escape(arg, smart=True):
-    """format arg with quotes
-
-    @param smart(bool): if True, only escape if needed
-    """
-    if smart and not " " in arg and not '"' in arg:
-        return arg
-    return '"' + arg.replace('"', '\\"') + '"'
-
-
-def get_cmd_choices(cmd=None, parser=None):
-    try:
-        choices = parser._subparsers._group_actions[0].choices
-        return choices[cmd] if cmd is not None else choices
-    except (KeyError, AttributeError):
-        raise exceptions.NotFound
-
-
-def get_use_args(host, args, use, verbose=False, parser=None):
-    """format args for argparse parser with values prefilled
-
-    @param host(JP): jp instance
-    @param args(list(str)): arguments to use
-    @param use(dict[str, str]): arguments to fill if found in parser
-    @param verbose(bool): if True a message will be displayed when argument is used or not
-    @param parser(argparse.ArgumentParser): parser to use
-    @return (tuple[list[str],list[str]]): 2 args lists:
-        - parser args, i.e. given args corresponding to parsers
-        - use args, i.e. generated args from use
-    """
-    # FIXME: positional args are not handled correclty
-    #        if there is more that one, the position is not corrected
-    if parser is None:
-        parser = host.parser
-
-    # we check not optional args to see if there
-    # is a corresonding parser
-    # else USE args would not work correctly (only for current parser)
-    parser_args = []
-    for arg in args:
-        if arg.startswith("-"):
-            break
-        try:
-            parser = get_cmd_choices(arg, parser)
-        except exceptions.NotFound:
-            break
-        parser_args.append(arg)
-
-    # post_args are remaning given args,
-    # without the ones corresponding to parsers
-    post_args = args[len(parser_args) :]
-
-    opt_args = []
-    pos_args = []
-    actions = {a.dest: a for a in parser._actions}
-    for arg, value in use.items():
-        try:
-            if arg == "item" and not "item" in actions:
-                # small hack when --item is appended to a --items list
-                arg = "items"
-            action = actions[arg]
-        except KeyError:
-            if verbose:
-                host.disp(
-                    _(
-                        "ignoring {name}={value}, not corresponding to any argument (in USE)"
-                    ).format(name=arg, value=escape(value))
-                )
-        else:
-            if verbose:
-                host.disp(
-                    _("arg {name}={value} (in USE)").format(
-                        name=arg, value=escape(value)
-                    )
-                )
-            if not action.option_strings:
-                pos_args.append(value)
-            else:
-                opt_args.append(action.option_strings[0])
-                opt_args.append(value)
-    return parser_args, opt_args + pos_args + post_args
--- a/sat_frontends/jp/base.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1435 +0,0 @@
-#!/usr/bin/env python3
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-import asyncio
-from libervia.backend.core.i18n import _
-
-### logging ###
-import logging as log
-log.basicConfig(level=log.WARNING,
-                format='[%(name)s] %(message)s')
-###
-
-import sys
-import os
-import os.path
-import argparse
-import inspect
-import tty
-import termios
-from pathlib import Path
-from glob import iglob
-from typing import Optional, Set, Union
-from importlib import import_module
-from sat_frontends.tools.jid import JID
-from libervia.backend.tools import config
-from libervia.backend.tools.common import dynamic_import
-from libervia.backend.tools.common import uri
-from libervia.backend.tools.common import date_utils
-from libervia.backend.tools.common import utils
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.core import exceptions
-import sat_frontends.jp
-from sat_frontends.jp.loops import QuitException, get_jp_loop
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.bridge.bridge_frontend import BridgeException
-from sat_frontends.tools import misc
-import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
-from collections import OrderedDict
-
-## bridge handling
-# we get bridge name from conf and initialise the right class accordingly
-main_config = config.parse_main_conf()
-bridge_name = config.config_get(main_config, '', 'bridge', 'dbus')
-JPLoop = get_jp_loop(bridge_name)
-
-
-try:
-    import progressbar
-except ImportError:
-    msg = (_('ProgressBar not available, please download it at '
-             'http://pypi.python.org/pypi/progressbar\n'
-             'Progress bar deactivated\n--\n'))
-    print(msg, file=sys.stderr)
-    progressbar=None
-
-#consts
-DESCRIPTION = """This software is a command line tool for XMPP.
-Get the latest version at """ + C.APP_URL
-
-COPYLEFT = """Copyright (C) 2009-2021 Jérôme Poisson, Adrien Cossa
-This program comes with ABSOLUTELY NO WARRANTY;
-This is free software, and you are welcome to redistribute it under certain conditions.
-"""
-
-PROGRESS_DELAY = 0.1 # the progression will be checked every PROGRESS_DELAY s
-
-
-def date_decoder(arg):
-    return date_utils.date_parse_ext(arg, default_tz=date_utils.TZ_LOCAL)
-
-
-class LiberviaCli:
-    """
-    This class can be use to establish a connection with the
-    bridge. Moreover, it should manage a main loop.
-
-    To use it, you mainly have to redefine the method run to perform
-    specify what kind of operation you want to perform.
-
-    """
-    def __init__(self):
-        """
-
-        @attribute quit_on_progress_end (bool): set to False if you manage yourself
-            exiting, or if you want the user to stop by himself
-        @attribute progress_success(callable): method to call when progress just started
-            by default display a message
-        @attribute progress_success(callable): method to call when progress is
-            successfully finished by default display a message
-        @attribute progress_failure(callable): method to call when progress failed
-            by default display a message
-        """
-        self.sat_conf = main_config
-        self.set_color_theme()
-        bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge')
-        if bridge_module is None:
-            log.error("Can't import {} bridge".format(bridge_name))
-            sys.exit(1)
-
-        self.bridge = bridge_module.AIOBridge()
-        self._onQuitCallbacks = []
-
-    def get_config(self, name, section=C.CONFIG_SECTION, default=None):
-        """Retrieve a setting value from sat.conf"""
-        return config.config_get(self.sat_conf, section, name, default=default)
-
-    def guess_background(self):
-        # cf. https://unix.stackexchange.com/a/245568 (thanks!)
-        try:
-            # for VTE based terminals
-            vte_version = int(os.getenv("VTE_VERSION", 0))
-        except ValueError:
-            vte_version = 0
-
-        color_fg_bg = os.getenv("COLORFGBG")
-
-        if ((sys.stdin.isatty() and sys.stdout.isatty()
-             and (
-                 # XTerm
-                 os.getenv("XTERM_VERSION")
-                 # Konsole
-                 or os.getenv("KONSOLE_VERSION")
-                 # All VTE based terminals
-                 or vte_version >= 3502
-             ))):
-            # ANSI escape sequence
-            stdin_fd = sys.stdin.fileno()
-            old_settings = termios.tcgetattr(stdin_fd)
-            try:
-                tty.setraw(sys.stdin.fileno())
-                # we request background color
-                sys.stdout.write("\033]11;?\a")
-                sys.stdout.flush()
-                expected = "\033]11;rgb:"
-                for c in expected:
-                    ch = sys.stdin.read(1)
-                    if ch != c:
-                        # background id is not supported, we default to "dark"
-                        # TODO: log something?
-                        return 'dark'
-                red, green, blue = [
-                    int(c, 16)/65535 for c in sys.stdin.read(14).split('/')
-                ]
-                # '\a' is the last character
-                sys.stdin.read(1)
-            finally:
-                termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
-
-            lum = utils.per_luminance(red, green, blue)
-            if lum <= 0.5:
-                return 'dark'
-            else:
-                return 'light'
-        elif color_fg_bg:
-            # no luck with ANSI escape sequence, we try COLORFGBG environment variable
-            try:
-                bg = int(color_fg_bg.split(";")[-1])
-            except ValueError:
-                return "dark"
-            if bg in list(range(7)) + [8]:
-                return "dark"
-            else:
-                return "light"
-        else:
-            # no autodetection method found
-            return "dark"
-
-    def set_color_theme(self):
-        background = self.get_config('background', default='auto')
-        if background == 'auto':
-            background = self.guess_background()
-        if background not in ('dark', 'light'):
-            raise exceptions.ConfigError(_(
-                'Invalid value set for "background" ({background}), please check '
-                'your settings in libervia.conf').format(
-                    background=repr(background)
-                ))
-        self.background = background
-        if background == 'light':
-            C.A_HEADER = A.FG_MAGENTA
-            C.A_SUBHEADER = A.BOLD + A.FG_RED
-            C.A_LEVEL_COLORS = (C.A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
-            C.A_SUCCESS = A.FG_GREEN
-            C.A_FAILURE = A.BOLD + A.FG_RED
-            C.A_WARNING = A.FG_RED
-            C.A_PROMPT_PATH = A.FG_BLUE
-            C.A_PROMPT_SUF = A.BOLD
-            C.A_DIRECTORY = A.BOLD + A.FG_MAGENTA
-            C.A_FILE = A.FG_BLACK
-
-    def _bridge_connected(self):
-        self.parser = argparse.ArgumentParser(
-            formatter_class=argparse.RawDescriptionHelpFormatter, description=DESCRIPTION)
-        self._make_parents()
-        self.add_parser_options()
-        self.subparsers = self.parser.add_subparsers(
-            title=_('Available commands'), dest='command', required=True)
-
-        # progress attributes
-        self._progress_id = None # TODO: manage several progress ids
-        self.quit_on_progress_end = True
-
-        # outputs
-        self._outputs = {}
-        for type_ in C.OUTPUT_TYPES:
-            self._outputs[type_] = OrderedDict()
-        self.default_output = {}
-
-        self.own_jid = None  # must be filled at runtime if needed
-
-    @property
-    def progress_id(self):
-        return self._progress_id
-
-    async def set_progress_id(self, progress_id):
-        # because we use async, we need an explicit setter
-        self._progress_id = progress_id
-        await self.replay_cache('progress_ids_cache')
-
-    @property
-    def watch_progress(self):
-        try:
-            self.pbar
-        except AttributeError:
-            return False
-        else:
-            return True
-
-    @watch_progress.setter
-    def watch_progress(self, watch_progress):
-        if watch_progress:
-            self.pbar = None
-
-    @property
-    def verbosity(self):
-        try:
-            return self.args.verbose
-        except AttributeError:
-            return 0
-
-    async def replay_cache(self, cache_attribute):
-        """Replay cached signals
-
-        @param cache_attribute(str): name of the attribute containing the cache
-            if the attribute doesn't exist, there is no cache and the call is ignored
-            else the cache must be a list of tuples containing the replay callback as
-            first item, then the arguments to use
-        """
-        try:
-            cache = getattr(self, cache_attribute)
-        except AttributeError:
-            pass
-        else:
-            for cache_data in cache:
-                await cache_data[0](*cache_data[1:])
-
-    def disp(self, msg, verbosity=0, error=False, end='\n'):
-        """Print a message to user
-
-        @param msg(unicode): message to print
-        @param verbosity(int): minimal verbosity to display the message
-        @param error(bool): if True, print to stderr instead of stdout
-        @param end(str): string appended after the last value, default a newline
-        """
-        if self.verbosity >= verbosity:
-            if error:
-                print(msg, end=end, file=sys.stderr)
-            else:
-                print(msg, end=end)
-
-    async def output(self, type_, name, extra_outputs, data):
-        if name in extra_outputs:
-            method = extra_outputs[name]
-        else:
-            method = self._outputs[type_][name]['callback']
-
-        ret = method(data)
-        if inspect.isawaitable(ret):
-            await ret
-
-    def add_on_quit_callback(self, callback, *args, **kwargs):
-        """Add a callback which will be called on quit command
-
-        @param callback(callback): method to call
-        """
-        self._onQuitCallbacks.append((callback, args, kwargs))
-
-    def get_output_choices(self, output_type):
-        """Return valid output filters for output_type
-
-        @param output_type: True for default,
-            else can be any registered type
-        """
-        return list(self._outputs[output_type].keys())
-
-    def _make_parents(self):
-        self.parents = {}
-
-        # we have a special case here as the start-session option is present only if
-        # connection is not needed, so we create two similar parents, one with the
-        # option, the other one without it
-        for parent_name in ('profile', 'profile_session'):
-            parent = self.parents[parent_name] = argparse.ArgumentParser(add_help=False)
-            parent.add_argument(
-                "-p", "--profile", action="store", type=str, default='@DEFAULT@',
-                help=_("Use PROFILE profile key (default: %(default)s)"))
-            parent.add_argument(
-                "--pwd", action="store", metavar='PASSWORD',
-                help=_("Password used to connect profile, if necessary"))
-
-        profile_parent, profile_session_parent = (self.parents['profile'],
-                                                  self.parents['profile_session'])
-
-        connect_short, connect_long, connect_action, connect_help = (
-            "-c", "--connect", "store_true",
-            _("Connect the profile before doing anything else")
-        )
-        profile_parent.add_argument(
-            connect_short, connect_long, action=connect_action, help=connect_help)
-
-        profile_session_connect_group = profile_session_parent.add_mutually_exclusive_group()
-        profile_session_connect_group.add_argument(
-            connect_short, connect_long, action=connect_action, help=connect_help)
-        profile_session_connect_group.add_argument(
-            "--start-session", action="store_true",
-            help=_("Start a profile session without connecting"))
-
-        progress_parent = self.parents['progress'] = argparse.ArgumentParser(
-            add_help=False)
-        if progressbar:
-            progress_parent.add_argument(
-                "-P", "--progress", action="store_true", help=_("Show progress bar"))
-
-        verbose_parent = self.parents['verbose'] = argparse.ArgumentParser(add_help=False)
-        verbose_parent.add_argument(
-            '--verbose', '-v', action='count', default=0,
-            help=_("Add a verbosity level (can be used multiple times)"))
-
-        quiet_parent = self.parents['quiet'] = argparse.ArgumentParser(add_help=False)
-        quiet_parent.add_argument(
-            '--quiet', '-q', action='store_true',
-            help=_("be quiet (only output machine readable data)"))
-
-        draft_parent = self.parents['draft'] = argparse.ArgumentParser(add_help=False)
-        draft_group = draft_parent.add_argument_group(_('draft handling'))
-        draft_group.add_argument(
-            "-D", "--current", action="store_true", help=_("load current draft"))
-        draft_group.add_argument(
-            "-F", "--draft-path", type=Path, help=_("path to a draft file to retrieve"))
-
-
-    def make_pubsub_group(self, flags, defaults):
-        """Generate pubsub options according to flags
-
-        @param flags(iterable[unicode]): see [CommandBase.__init__]
-        @param defaults(dict[unicode, unicode]): help text for default value
-            key can be "service" or "node"
-            value will be set in " (DEFAULT: {value})", or can be None to remove DEFAULT
-        @return (ArgumentParser): parser to add
-        """
-        flags = misc.FlagsHandler(flags)
-        parent = argparse.ArgumentParser(add_help=False)
-        pubsub_group = parent.add_argument_group('pubsub')
-        pubsub_group.add_argument("-u", "--pubsub-url",
-                                  help=_("Pubsub URL (xmpp or http)"))
-
-        service_help = _("JID of the PubSub service")
-        if not flags.service:
-            default = defaults.pop('service', _('PEP service'))
-            if default is not None:
-                service_help += _(" (DEFAULT: {default})".format(default=default))
-        pubsub_group.add_argument("-s", "--service", default='',
-                                  help=service_help)
-
-        node_help = _("node to request")
-        if not flags.node:
-            default = defaults.pop('node', _('standard node'))
-            if default is not None:
-                node_help += _(" (DEFAULT: {default})".format(default=default))
-        pubsub_group.add_argument("-n", "--node", default='', help=node_help)
-
-        if flags.single_item:
-            item_help = ("item to retrieve")
-            if not flags.item:
-                default = defaults.pop('item', _('last item'))
-                if default is not None:
-                    item_help += _(" (DEFAULT: {default})".format(default=default))
-            pubsub_group.add_argument("-i", "--item", default='',
-                                      help=item_help)
-            pubsub_group.add_argument(
-                "-L", "--last-item", action='store_true', help=_('retrieve last item'))
-        elif flags.multi_items:
-            # mutiple items, this activate several features: max-items, RSM, MAM
-            # and Orbder-by
-            pubsub_group.add_argument(
-                "-i", "--item", action='append', dest='items', default=[],
-                help=_("items to retrieve (DEFAULT: all)"))
-            if not flags.no_max:
-                max_group = pubsub_group.add_mutually_exclusive_group()
-                # XXX: defaut value for --max-items or --max is set in parse_pubsub_args
-                max_group.add_argument(
-                    "-M", "--max-items", dest="max", type=int,
-                    help=_("maximum number of items to get ({no_limit} to get all items)"
-                           .format(no_limit=C.NO_LIMIT)))
-                # FIXME: it could be possible to no duplicate max (between pubsub
-                #        max-items and RSM max)should not be duplicated, RSM could be
-                #        used when available and pubsub max otherwise
-                max_group.add_argument(
-                    "-m", "--max", dest="rsm_max", type=int,
-                    help=_("maximum number of items to get per page (DEFAULT: 10)"))
-
-            # RSM
-
-            rsm_page_group = pubsub_group.add_mutually_exclusive_group()
-            rsm_page_group.add_argument(
-                "-a", "--after", dest="rsm_after",
-                help=_("find page after this item"), metavar='ITEM_ID')
-            rsm_page_group.add_argument(
-                "-b", "--before", dest="rsm_before",
-                help=_("find page before this item"), metavar='ITEM_ID')
-            rsm_page_group.add_argument(
-                "--index", dest="rsm_index", type=int,
-                help=_("index of the first item to retrieve"))
-
-
-            # MAM
-
-            pubsub_group.add_argument(
-                "-f", "--filter", dest='mam_filters', nargs=2,
-                action='append', default=[], help=_("MAM filters to use"),
-                metavar=("FILTER_NAME", "VALUE")
-            )
-
-            # Order-By
-
-            # TODO: order-by should be a list to handle several levels of ordering
-            #       but this is not yet done in SàT (and not really useful with
-            #       current specifications, as only "creation" and "modification" are
-            #       available)
-            pubsub_group.add_argument(
-                "-o", "--order-by", choices=[C.ORDER_BY_CREATION,
-                                             C.ORDER_BY_MODIFICATION],
-                help=_("how items should be ordered"))
-
-        if flags[C.CACHE]:
-            pubsub_group.add_argument(
-                "-C", "--no-cache", dest="use_cache", action='store_false',
-                help=_("don't use Pubsub cache")
-            )
-
-        if not flags.all_used:
-            raise exceptions.InternalError('unknown flags: {flags}'.format(
-                flags=', '.join(flags.unused)))
-        if defaults:
-            raise exceptions.InternalError(f'unused defaults: {defaults}')
-
-        return parent
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            '--version',
-            action='version',
-            version=("{name} {version} {copyleft}".format(
-                name = C.APP_NAME,
-                version = self.version,
-                copyleft = COPYLEFT))
-        )
-
-    def register_output(self, type_, name, callback, description="", default=False):
-        if type_ not in C.OUTPUT_TYPES:
-            log.error("Invalid output type {}".format(type_))
-            return
-        self._outputs[type_][name] = {'callback': callback,
-                                      'description': description
-                                     }
-        if default:
-            if type_ in self.default_output:
-                self.disp(
-                    _('there is already a default output for {type}, ignoring new one')
-                    .format(type=type_)
-                )
-            else:
-                self.default_output[type_] = name
-
-
-    def parse_output_options(self):
-        options = self.command.args.output_opts
-        options_dict = {}
-        for option in options:
-            try:
-                key, value = option.split('=', 1)
-            except ValueError:
-                key, value = option, None
-            options_dict[key.strip()] = value.strip() if value is not None else None
-        return options_dict
-
-    def check_output_options(self, accepted_set, options):
-        if not accepted_set.issuperset(options):
-            self.disp(
-                _("The following output options are invalid: {invalid_options}").format(
-                invalid_options = ', '.join(set(options).difference(accepted_set))),
-                error=True)
-            self.quit(C.EXIT_BAD_ARG)
-
-    def import_plugins(self):
-        """Automaticaly import commands and outputs in jp
-
-        looks from modules names cmd_*.py in jp path and import them
-        """
-        path = os.path.dirname(sat_frontends.jp.__file__)
-        # XXX: outputs must be imported before commands as they are used for arguments
-        for type_, pattern in ((C.PLUGIN_OUTPUT, 'output_*.py'),
-                               (C.PLUGIN_CMD, 'cmd_*.py')):
-            modules = (
-                os.path.splitext(module)[0]
-                for module in map(os.path.basename, iglob(os.path.join(path, pattern))))
-            for module_name in modules:
-                module_path = "sat_frontends.jp." + module_name
-                try:
-                    module = import_module(module_path)
-                    self.import_plugin_module(module, type_)
-                except ImportError as e:
-                    self.disp(
-                        _("Can't import {module_path} plugin, ignoring it: {e}")
-                        .format(module_path=module_path, e=e),
-                        error=True)
-                except exceptions.CancelError:
-                    continue
-                except exceptions.MissingModule as e:
-                    self.disp(_("Missing module for plugin {name}: {missing}".format(
-                        name = module_path,
-                        missing = e)), error=True)
-
-
-    def import_plugin_module(self, module, type_):
-        """add commands or outpus from a module to jp
-
-        @param module: module containing commands or outputs
-        @param type_(str): one of C_PLUGIN_*
-        """
-        try:
-            class_names =  getattr(module, '__{}__'.format(type_))
-        except AttributeError:
-            log.disp(
-                _("Invalid plugin module [{type}] {module}")
-                .format(type=type_, module=module),
-                error=True)
-            raise ImportError
-        else:
-            for class_name in class_names:
-                cls = getattr(module, class_name)
-                cls(self)
-
-    def get_xmpp_uri_from_http(self, http_url):
-        """parse HTML page at http(s) URL, and looks for xmpp: uri"""
-        if http_url.startswith('https'):
-            scheme = 'https'
-        elif http_url.startswith('http'):
-            scheme = 'http'
-        else:
-            raise exceptions.InternalError('An HTTP scheme is expected in this method')
-        self.disp(f"{scheme.upper()} URL found, trying to find associated xmpp: URI", 1)
-        # HTTP URL, we try to find xmpp: links
-        try:
-            from lxml import etree
-        except ImportError:
-            self.disp(
-                "lxml module must be installed to use http(s) scheme, please install it "
-                "with \"pip install lxml\"",
-                error=True)
-            self.quit(1)
-        import urllib.request, urllib.error, urllib.parse
-        parser = etree.HTMLParser()
-        try:
-            root = etree.parse(urllib.request.urlopen(http_url), parser)
-        except etree.XMLSyntaxError as e:
-            self.disp(_("Can't parse HTML page : {msg}").format(msg=e))
-            links = []
-        else:
-            links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]")
-        if not links:
-            self.disp(
-                _('Could not find alternate "xmpp:" URI, can\'t find associated XMPP '
-                  'PubSub node/item'),
-                error=True)
-            self.quit(1)
-        xmpp_uri = links[0].get('href')
-        return xmpp_uri
-
-    def parse_pubsub_args(self):
-        if self.args.pubsub_url is not None:
-            url = self.args.pubsub_url
-
-            if url.startswith('http'):
-                # http(s) URL, we try to retrieve xmpp one from there
-                url = self.get_xmpp_uri_from_http(url)
-
-            try:
-                uri_data = uri.parse_xmpp_uri(url)
-            except ValueError:
-                self.parser.error(_('invalid XMPP URL: {url}').format(url=url))
-            else:
-                if uri_data['type'] == 'pubsub':
-                    # URL is alright, we only set data not already set by other options
-                    if not self.args.service:
-                        self.args.service = uri_data['path']
-                    if not self.args.node:
-                        self.args.node = uri_data['node']
-                    uri_item = uri_data.get('item')
-                    if uri_item:
-                        # there is an item in URI
-                        # we use it only if item is not already set
-                        # and item_last is not used either
-                        try:
-                            item = self.args.item
-                        except AttributeError:
-                            try:
-                                items = self.args.items
-                            except AttributeError:
-                                self.disp(
-                                    _("item specified in URL but not needed in command, "
-                                      "ignoring it"),
-                                    error=True)
-                            else:
-                                if not items:
-                                    self.args.items = [uri_item]
-                        else:
-                            if not item:
-                                try:
-                                    item_last = self.args.item_last
-                                except AttributeError:
-                                    item_last = False
-                                if not item_last:
-                                    self.args.item = uri_item
-                else:
-                    self.parser.error(
-                        _('XMPP URL is not a pubsub one: {url}').format(url=url)
-                    )
-        flags = self.args._cmd._pubsub_flags
-        # we check required arguments here instead of using add_arguments' required option
-        # because the required argument can be set in URL
-        if C.SERVICE in flags and not self.args.service:
-            self.parser.error(_("argument -s/--service is required"))
-        if C.NODE in flags and not self.args.node:
-            self.parser.error(_("argument -n/--node is required"))
-        if C.ITEM in flags and not self.args.item:
-            self.parser.error(_("argument -i/--item is required"))
-
-        # FIXME: mutually groups can't be nested in a group and don't support title
-        #        so we check conflict here. This may be fixed in Python 3, to be checked
-        try:
-            if self.args.item and self.args.item_last:
-                self.parser.error(
-                    _("--item and --item-last can't be used at the same time"))
-        except AttributeError:
-            pass
-
-        try:
-            max_items = self.args.max
-            rsm_max = self.args.rsm_max
-        except AttributeError:
-            pass
-        else:
-            # we need to set a default value for max, but we need to know if we want
-            # to use pubsub's max or RSM's max. The later is used if any RSM or MAM
-            # argument is set
-            if max_items is None and rsm_max is None:
-                to_check = ('mam_filters', 'rsm_max', 'rsm_after', 'rsm_before',
-                            'rsm_index')
-                if any((getattr(self.args, name) for name in to_check)):
-                    # we use RSM
-                    self.args.rsm_max = 10
-                else:
-                    # we use pubsub without RSM
-                    self.args.max = 10
-            if self.args.max is None:
-                self.args.max = C.NO_LIMIT
-
-    async def main(self, args, namespace):
-        try:
-            await self.bridge.bridge_connect()
-        except Exception as e:
-            if isinstance(e, exceptions.BridgeExceptionNoService):
-                print(
-                    _("Can't connect to Libervia backend, are you sure that it's "
-                      "launched ?")
-                )
-                self.quit(C.EXIT_BACKEND_NOT_FOUND, raise_exc=False)
-            elif isinstance(e, exceptions.BridgeInitError):
-                print(_("Can't init bridge"))
-                self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
-            else:
-                print(
-                    _("Error while initialising bridge: {e}").format(e=e)
-                )
-                self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
-            return
-        await self.bridge.ready_get()
-        self.version = await self.bridge.version_get()
-        self._bridge_connected()
-        self.import_plugins()
-        try:
-            self.args = self.parser.parse_args(args, namespace=None)
-            if self.args._cmd._use_pubsub:
-                self.parse_pubsub_args()
-            await self.args._cmd.run()
-        except SystemExit as e:
-            self.quit(e.code, raise_exc=False)
-            return
-        except QuitException:
-            return
-
-    def _run(self, args=None, namespace=None):
-        self.loop = JPLoop()
-        self.loop.run(self, args, namespace)
-
-    @classmethod
-    def run(cls):
-        cls()._run()
-
-    def _read_stdin(self, stdin_fut):
-        """Callback called by ainput to read stdin"""
-        line = sys.stdin.readline()
-        if line:
-            stdin_fut.set_result(line.rstrip(os.linesep))
-        else:
-            stdin_fut.set_exception(EOFError())
-
-    async def ainput(self, msg=''):
-        """Asynchronous version of buildin "input" function"""
-        self.disp(msg, end=' ')
-        sys.stdout.flush()
-        loop = asyncio.get_running_loop()
-        stdin_fut = loop.create_future()
-        loop.add_reader(sys.stdin, self._read_stdin, stdin_fut)
-        return await stdin_fut
-
-    async def confirm(self, message):
-        """Request user to confirm action, return answer as boolean"""
-        res = await self.ainput(f"{message} (y/N)? ")
-        return res in ("y", "Y")
-
-    async def confirm_or_quit(self, message, cancel_message=_("action cancelled by user")):
-        """Request user to confirm action, and quit if he doesn't"""
-        confirmed = await self.confirm(message)
-        if not confirmed:
-            self.disp(cancel_message)
-            self.quit(C.EXIT_USER_CANCELLED)
-
-    def quit_from_signal(self, exit_code=0):
-        r"""Same as self.quit, but from a signal handler
-
-        /!\: return must be used after calling this method !
-        """
-        # XXX: python-dbus will show a traceback if we exit in a signal handler
-        # so we use this little timeout trick to avoid it
-        self.loop.call_later(0, self.quit, exit_code)
-
-    def quit(self, exit_code=0, raise_exc=True):
-        """Terminate the execution with specified exit_code
-
-        This will stop the loop.
-        @param exit_code(int): code to return when quitting the program
-        @param raise_exp(boolean): if True raise a QuitException to stop code execution
-            The default value should be used most of time.
-        """
-        # first the onQuitCallbacks
-        try:
-            callbacks_list = self._onQuitCallbacks
-        except AttributeError:
-            pass
-        else:
-            for callback, args, kwargs in callbacks_list:
-                callback(*args, **kwargs)
-
-        self.loop.quit(exit_code)
-        if raise_exc:
-            raise QuitException
-
-    async def check_jids(self, jids):
-        """Check jids validity, transform roster name to corresponding jids
-
-        @param profile: profile name
-        @param jids: list of jids
-        @return: List of jids
-
-        """
-        names2jid = {}
-        nodes2jid = {}
-
-        try:
-            contacts = await self.bridge.contacts_get(self.profile)
-        except BridgeException as e:
-            if e.classname == "AttributeError":
-                # we may get an AttributeError if we use a component profile
-                # as components don't have roster
-                contacts = []
-            else:
-                raise e
-
-        for contact in contacts:
-            jid_s, attr, groups = contact
-            _jid = JID(jid_s)
-            try:
-                names2jid[attr["name"].lower()] = jid_s
-            except KeyError:
-                pass
-
-            if _jid.node:
-                nodes2jid[_jid.node.lower()] = jid_s
-
-        def expand_jid(jid):
-            _jid = jid.lower()
-            if _jid in names2jid:
-                expanded = names2jid[_jid]
-            elif _jid in nodes2jid:
-                expanded = nodes2jid[_jid]
-            else:
-                expanded = jid
-            return expanded
-
-        def check(jid):
-            if not jid.is_valid:
-                log.error (_("%s is not a valid JID !"), jid)
-                self.quit(1)
-
-        dest_jids=[]
-        try:
-            for i in range(len(jids)):
-                dest_jids.append(expand_jid(jids[i]))
-                check(dest_jids[i])
-        except AttributeError:
-            pass
-
-        return dest_jids
-
-    async def a_pwd_input(self, msg=''):
-        """Like ainput but with echo disabled (useful for passwords)"""
-        # we disable echo, code adapted from getpass standard module which has been
-        # written by Piers Lauder (original), Guido van Rossum (Windows support and
-        # cleanup) and Gregory P. Smith (tty support & GetPassWarning), a big thanks
-        # to them (and for all the amazing work on Python).
-        stdin_fd = sys.stdin.fileno()
-        old = termios.tcgetattr(sys.stdin)
-        new = old[:]
-        new[3] &= ~termios.ECHO
-        tcsetattr_flags = termios.TCSAFLUSH
-        if hasattr(termios, 'TCSASOFT'):
-            tcsetattr_flags |= termios.TCSASOFT
-        try:
-            termios.tcsetattr(stdin_fd, tcsetattr_flags, new)
-            pwd = await self.ainput(msg=msg)
-        finally:
-            termios.tcsetattr(stdin_fd, tcsetattr_flags, old)
-            sys.stderr.flush()
-        self.disp('')
-        return pwd
-
-    async def connect_or_prompt(self, method, err_msg=None):
-        """Try to connect/start profile session and prompt for password if needed
-
-        @param method(callable): bridge method to either connect or start profile session
-            It will be called with password as sole argument, use lambda to do the call
-            properly
-        @param err_msg(str): message to show if connection fail
-        """
-        password = self.args.pwd
-        while True:
-            try:
-                await method(password or '')
-            except Exception as e:
-                if ((isinstance(e, BridgeException)
-                     and e.classname == 'PasswordError'
-                     and self.args.pwd is None)):
-                    if password is not None:
-                        self.disp(A.color(C.A_WARNING, _("invalid password")))
-                    password = await self.a_pwd_input(
-                        _("please enter profile password:"))
-                else:
-                    self.disp(err_msg.format(profile=self.profile, e=e), error=True)
-                    self.quit(C.EXIT_ERROR)
-            else:
-                break
-
-    async def connect_profile(self):
-        """Check if the profile is connected and do it if requested
-
-        @exit: - 1 when profile is not connected and --connect is not set
-               - 1 when the profile doesn't exists
-               - 1 when there is a connection error
-        """
-        # FIXME: need better exit codes
-
-        self.profile = await self.bridge.profile_name_get(self.args.profile)
-
-        if not self.profile:
-            log.error(
-                _("The profile [{profile}] doesn't exist")
-                .format(profile=self.args.profile)
-            )
-            self.quit(C.EXIT_ERROR)
-
-        try:
-            start_session = self.args.start_session
-        except AttributeError:
-            pass
-        else:
-            if start_session:
-                await self.connect_or_prompt(
-                    lambda pwd: self.bridge.profile_start_session(pwd, self.profile),
-                    err_msg="Can't start {profile}'s session: {e}"
-                )
-                return
-            elif not await self.bridge.profile_is_session_started(self.profile):
-                if not self.args.connect:
-                    self.disp(_(
-                        "Session for [{profile}] is not started, please start it "
-                        "before using jp, or use either --start-session or --connect "
-                        "option"
-                        .format(profile=self.profile)
-                    ), error=True)
-                    self.quit(1)
-            elif not getattr(self.args, "connect", False):
-                return
-
-
-        if not hasattr(self.args, 'connect'):
-            # a profile can be present without connect option (e.g. on profile
-            # creation/deletion)
-            return
-        elif self.args.connect is True:  # if connection is asked, we connect the profile
-            await self.connect_or_prompt(
-                lambda pwd: self.bridge.connect(self.profile, pwd, {}),
-                err_msg = 'Can\'t connect profile "{profile!s}": {e}'
-            )
-            return
-        else:
-            if not await self.bridge.is_connected(self.profile):
-                log.error(
-                    _("Profile [{profile}] is not connected, please connect it "
-                      "before using jp, or use --connect option")
-                    .format(profile=self.profile)
-                )
-                self.quit(1)
-
-    async def get_full_jid(self, param_jid):
-        """Return the full jid if possible (add main resource when find a bare jid)"""
-        # TODO: to be removed, bare jid should work with all commands, notably for file
-        #   as backend now handle jingles message initiation
-        _jid = JID(param_jid)
-        if not _jid.resource:
-            #if the resource is not given, we try to add the main resource
-            main_resource = await self.bridge.main_resource_get(param_jid, self.profile)
-            if main_resource:
-                return f"{_jid.bare}/{main_resource}"
-        return param_jid
-
-    async def get_profile_jid(self):
-        """Retrieve current profile bare JID if possible"""
-        full_jid = await self.bridge.param_get_a_async(
-            "JabberID", "Connection", profile_key=self.profile
-        )
-        return full_jid.rsplit("/", 1)[0]
-
-
-class CommandBase:
-
-    def __init__(
-        self,
-        host: LiberviaCli,
-        name: str,
-        use_profile: bool = True,
-        use_output: Union[bool, str] = False,
-        extra_outputs: Optional[dict] = None,
-        need_connect: Optional[bool] = None,
-        help: Optional[str] = None,
-        **kwargs
-    ):
-        """Initialise CommandBase
-
-        @param host: Jp instance
-        @param name: name of the new command
-        @param use_profile: if True, add profile selection/connection commands
-        @param use_output: if not False, add --output option
-        @param extra_outputs: list of command specific outputs:
-            key is output name ("default" to use as main output)
-            value is a callable which will format the output (data will be used as only
-            argument)
-            if a key already exists with normal outputs, the extra one will be used
-        @param need_connect: True if profile connection is needed
-            False else (profile session must still be started)
-            None to set auto value (i.e. True if use_profile is set)
-            Can't be set if use_profile is False
-        @param help: help message to display
-        @param **kwargs: args passed to ArgumentParser
-            use_* are handled directly, they can be:
-            - use_progress(bool): if True, add progress bar activation option
-                progress* signals will be handled
-            - use_verbose(bool): if True, add verbosity option
-            - use_pubsub(bool): if True, add pubsub options
-                mandatory arguments are controlled by pubsub_req
-            - use_draft(bool): if True, add draft handling options
-            ** other arguments **
-            - pubsub_flags(iterable[unicode]): tuple of flags to set pubsub options,
-              can be:
-                C.SERVICE: service is required
-                C.NODE: node is required
-                C.ITEM: item is required
-                C.SINGLE_ITEM: only one item is allowed
-        """
-        try: # If we have subcommands, host is a CommandBase and we need to use host.host
-            self.host = host.host
-        except AttributeError:
-            self.host = host
-
-        # --profile option
-        parents = kwargs.setdefault('parents', set())
-        if use_profile:
-            # self.host.parents['profile'] is an ArgumentParser with profile connection
-            # arguments
-            if need_connect is None:
-                need_connect = True
-            parents.add(
-                self.host.parents['profile' if need_connect else 'profile_session'])
-        else:
-            assert need_connect is None
-        self.need_connect = need_connect
-        # from this point, self.need_connect is None if connection is not needed at all
-        # False if session starting is needed, and True if full connection is needed
-
-        # --output option
-        if use_output:
-            if extra_outputs is None:
-                extra_outputs = {}
-            self.extra_outputs = extra_outputs
-            if use_output == True:
-                use_output = C.OUTPUT_TEXT
-            assert use_output in C.OUTPUT_TYPES
-            self._output_type = use_output
-            output_parent = argparse.ArgumentParser(add_help=False)
-            choices = set(self.host.get_output_choices(use_output))
-            choices.update(extra_outputs)
-            if not choices:
-                raise exceptions.InternalError(
-                    "No choice found for {} output type".format(use_output))
-            try:
-                default = self.host.default_output[use_output]
-            except KeyError:
-                if 'default' in choices:
-                    default = 'default'
-                elif 'simple' in choices:
-                    default = 'simple'
-                else:
-                    default = list(choices)[0]
-            output_parent.add_argument(
-                '--output', '-O', choices=sorted(choices), default=default,
-                help=_("select output format (default: {})".format(default)))
-            output_parent.add_argument(
-                '--output-option', '--oo', action="append", dest='output_opts',
-                default=[], help=_("output specific option"))
-            parents.add(output_parent)
-        else:
-            assert extra_outputs is None
-
-        self._use_pubsub = kwargs.pop('use_pubsub', False)
-        if self._use_pubsub:
-            flags = kwargs.pop('pubsub_flags', [])
-            defaults = kwargs.pop('pubsub_defaults', {})
-            parents.add(self.host.make_pubsub_group(flags, defaults))
-            self._pubsub_flags = flags
-
-        # other common options
-        use_opts = {k:v for k,v in kwargs.items() if k.startswith('use_')}
-        for param, do_use in use_opts.items():
-            opt=param[4:] # if param is use_verbose, opt is verbose
-            if opt not in self.host.parents:
-                raise exceptions.InternalError("Unknown parent option {}".format(opt))
-            del kwargs[param]
-            if do_use:
-                parents.add(self.host.parents[opt])
-
-        self.parser = host.subparsers.add_parser(name, help=help, **kwargs)
-        if hasattr(self, "subcommands"):
-            self.subparsers = self.parser.add_subparsers(dest='subcommand', required=True)
-        else:
-            self.parser.set_defaults(_cmd=self)
-        self.add_parser_options()
-
-    @property
-    def sat_conf(self):
-        return self.host.sat_conf
-
-    @property
-    def args(self):
-        return self.host.args
-
-    @property
-    def profile(self):
-        return self.host.profile
-
-    @property
-    def verbosity(self):
-        return self.host.verbosity
-
-    @property
-    def progress_id(self):
-        return self.host.progress_id
-
-    async def set_progress_id(self, progress_id):
-        return await self.host.set_progress_id(progress_id)
-
-    async def progress_started_handler(self, uid, metadata, profile):
-        if profile != self.profile:
-            return
-        if self.progress_id is None:
-            # the progress started message can be received before the id
-            # so we keep progress_started signals in cache to replay they
-            # when the progress_id is received
-            cache_data = (self.progress_started_handler, uid, metadata, profile)
-            try:
-                cache = self.host.progress_ids_cache
-            except AttributeError:
-                cache = self.host.progress_ids_cache = []
-            cache.append(cache_data)
-        else:
-            if self.host.watch_progress and uid == self.progress_id:
-                await self.on_progress_started(metadata)
-                while True:
-                    await asyncio.sleep(PROGRESS_DELAY)
-                    cont = await self.progress_update()
-                    if not cont:
-                        break
-
-    async def progress_finished_handler(self, uid, metadata, profile):
-        if profile != self.profile:
-            return
-        if uid == self.progress_id:
-            try:
-                self.host.pbar.finish()
-            except AttributeError:
-                pass
-            await self.on_progress_finished(metadata)
-            if self.host.quit_on_progress_end:
-                self.host.quit_from_signal()
-
-    async def progress_error_handler(self, uid, message, profile):
-        if profile != self.profile:
-            return
-        if uid == self.progress_id:
-            if self.args.progress:
-                self.disp('') # progress is not finished, so we skip a line
-            if self.host.quit_on_progress_end:
-                await self.on_progress_error(message)
-                self.host.quit_from_signal(C.EXIT_ERROR)
-
-    async def progress_update(self):
-        """This method is continualy called to update the progress bar
-
-        @return (bool): False to stop being called
-        """
-        data = await self.host.bridge.progress_get(self.progress_id, self.profile)
-        if data:
-            try:
-                size = data['size']
-            except KeyError:
-                self.disp(_("file size is not known, we can't show a progress bar"), 1,
-                          error=True)
-                return False
-            if self.host.pbar is None:
-                #first answer, we must construct the bar
-
-                # if the instance has a pbar_template attribute, it is used has model,
-                # else default one is used
-                # template is a list of part, where part can be either a str to show directly
-                # or a list where first argument is a name of a progressbar widget, and others
-                # are used as widget arguments
-                try:
-                    template = self.pbar_template
-                except AttributeError:
-                    template = [
-                        _("Progress: "), ["Percentage"], " ", ["Bar"], " ",
-                        ["FileTransferSpeed"], " ", ["ETA"]
-                    ]
-
-                widgets = []
-                for part in template:
-                    if isinstance(part, str):
-                        widgets.append(part)
-                    else:
-                        widget = getattr(progressbar, part.pop(0))
-                        widgets.append(widget(*part))
-
-                self.host.pbar = progressbar.ProgressBar(max_value=int(size), widgets=widgets)
-                self.host.pbar.start()
-
-            self.host.pbar.update(int(data['position']))
-
-        elif self.host.pbar is not None:
-            return False
-
-        await self.on_progress_update(data)
-
-        return True
-
-    async def on_progress_started(self, metadata):
-        """Called when progress has just started
-
-        can be overidden by a command
-        @param metadata(dict): metadata as sent by bridge.progress_started
-        """
-        self.disp(_("Operation started"), 2)
-
-    async def on_progress_update(self, metadata):
-        """Method called on each progress updata
-
-        can be overidden by a command to handle progress metadata
-        @para metadata(dict): metadata as returned by bridge.progress_get
-        """
-        pass
-
-    async def on_progress_finished(self, metadata):
-        """Called when progress has just finished
-
-        can be overidden by a command
-        @param metadata(dict): metadata as sent by bridge.progress_finished
-        """
-        self.disp(_("Operation successfully finished"), 2)
-
-    async def on_progress_error(self, e):
-        """Called when a progress failed
-
-        @param error_msg(unicode): error message as sent by bridge.progress_error
-        """
-        self.disp(_("Error while doing operation: {e}").format(e=e), error=True)
-
-    def disp(self, msg, verbosity=0, error=False, end='\n'):
-        return self.host.disp(msg, verbosity, error, end)
-
-    def output(self, data):
-        try:
-            output_type = self._output_type
-        except AttributeError:
-            raise exceptions.InternalError(
-                _('trying to use output when use_output has not been set'))
-        return self.host.output(output_type, self.args.output, self.extra_outputs, data)
-
-    def get_pubsub_extra(self, extra: Optional[dict] = None) -> str:
-        """Helper method to compute extra data from pubsub arguments
-
-        @param extra: base extra dict, or None to generate a new one
-        @return: serialised dict which can be used directly in the bridge for pubsub
-        """
-        if extra is None:
-            extra = {}
-        else:
-            intersection = {C.KEY_ORDER_BY}.intersection(list(extra.keys()))
-            if intersection:
-                raise exceptions.ConflictError(
-                    "given extra dict has conflicting keys with pubsub keys "
-                    "{intersection}".format(intersection=intersection))
-
-        # RSM
-
-        for attribute in ('max', 'after', 'before', 'index'):
-            key = 'rsm_' + attribute
-            if key in extra:
-                raise exceptions.ConflictError(
-                    "This key already exists in extra: u{key}".format(key=key))
-            value = getattr(self.args, key, None)
-            if value is not None:
-                extra[key] = str(value)
-
-        # MAM
-
-        if hasattr(self.args, 'mam_filters'):
-            for key, value in self.args.mam_filters:
-                key = 'filter_' + key
-                if key in extra:
-                    raise exceptions.ConflictError(
-                        "This key already exists in extra: u{key}".format(key=key))
-                extra[key] = value
-
-        # Order-By
-
-        try:
-            order_by = self.args.order_by
-        except AttributeError:
-            pass
-        else:
-            if order_by is not None:
-                extra[C.KEY_ORDER_BY] = self.args.order_by
-
-        # Cache
-        try:
-            use_cache = self.args.use_cache
-        except AttributeError:
-            pass
-        else:
-            if not use_cache:
-                extra[C.KEY_USE_CACHE] = use_cache
-
-        return data_format.serialise(extra)
-
-    def add_parser_options(self):
-        try:
-            subcommands = self.subcommands
-        except AttributeError:
-            # We don't have subcommands, the class need to implements add_parser_options
-            raise NotImplementedError
-
-        # now we add subcommands to ourself
-        for cls in subcommands:
-            cls(self)
-
-    def override_pubsub_flags(self, new_flags: Set[str]) -> None:
-        """Replace pubsub_flags given in __init__
-
-        useful when a command is extending an other command (e.g. blog command which does
-        the same as pubsub command, but with a default node)
-        """
-        self._pubsub_flags = new_flags
-
-    async def run(self):
-        """this method is called when a command is actually run
-
-        It set stuff like progression callbacks and profile connection
-        You should not overide this method: you should call self.start instead
-        """
-        # we keep a reference to run command, it may be useful e.g. for outputs
-        self.host.command = self
-
-        try:
-            show_progress = self.args.progress
-        except AttributeError:
-            # the command doesn't use progress bar
-            pass
-        else:
-            if show_progress:
-                self.host.watch_progress = True
-            # we need to register the following signal even if we don't display the
-            # progress bar
-            self.host.bridge.register_signal(
-                "progress_started", self.progress_started_handler)
-            self.host.bridge.register_signal(
-                "progress_finished", self.progress_finished_handler)
-            self.host.bridge.register_signal(
-                "progress_error", self.progress_error_handler)
-
-        if self.need_connect is not None:
-            await self.host.connect_profile()
-        await self.start()
-
-    async def start(self):
-        """This is the starting point of the command, this method must be overriden
-
-        at this point, profile are connected if needed
-        """
-        raise NotImplementedError
-
-
-class CommandAnswering(CommandBase):
-    """Specialised commands which answer to specific actions
-
-    to manage action_types answer,
-    """
-    action_callbacks = {} # XXX: set managed action types in a dict here:
-                          # key is the action_type, value is the callable
-                          # which will manage the answer. profile filtering is
-                          # already managed when callback is called
-
-    def __init__(self, *args, **kwargs):
-        super(CommandAnswering, self).__init__(*args, **kwargs)
-
-    async def on_action_new(
-        self,
-        action_data_s: str,
-        action_id: str,
-        security_limit: int,
-        profile: str
-    ) -> None:
-        if profile != self.profile:
-            return
-        action_data = data_format.deserialise(action_data_s)
-        try:
-            action_type = action_data['type']
-        except KeyError:
-            try:
-                xml_ui = action_data["xmlui"]
-            except KeyError:
-                pass
-            else:
-                self.on_xmlui(xml_ui)
-        else:
-            try:
-                callback = self.action_callbacks[action_type]
-            except KeyError:
-                pass
-            else:
-                await callback(action_data, action_id, security_limit, profile)
-
-    def on_xmlui(self, xml_ui):
-        """Display a dialog received from the backend.
-
-        @param xml_ui (unicode): dialog XML representation
-        """
-        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
-        #        should be available in the future
-        # TODO: XMLUI module
-        ui = ET.fromstring(xml_ui.encode('utf-8'))
-        dialog = ui.find("dialog")
-        if dialog is not None:
-            self.disp(dialog.findtext("message"), error=dialog.get("level") == "error")
-
-    async def start_answering(self):
-        """Auto reply to confirmation requests"""
-        self.host.bridge.register_signal("action_new", self.on_action_new)
-        actions = await self.host.bridge.actions_get(self.profile)
-        for action_data_s, action_id, security_limit in actions:
-            await self.on_action_new(action_data_s, action_id, security_limit, self.profile)
--- a/sat_frontends/jp/cmd_account.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,253 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-"""This module permits to manage XMPP accounts using in-band registration (XEP-0077)"""
-
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.bridge.bridge_frontend import BridgeException
-from libervia.backend.core.log import getLogger
-from libervia.backend.core.i18n import _
-from sat_frontends.jp import base
-from sat_frontends.tools import jid
-
-
-log = getLogger(__name__)
-
-__commands__ = ["Account"]
-
-
-class AccountCreate(base.CommandBase):
-    def __init__(self, host):
-        super(AccountCreate, self).__init__(
-            host,
-            "create",
-            use_profile=False,
-            use_verbose=True,
-            help=_("create a XMPP account"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid", help=_("jid to create")
-        )
-        self.parser.add_argument(
-            "password", help=_("password of the account")
-        )
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            help=_(
-                "create a profile to use this account (default: don't create profile)"
-            ),
-        )
-        self.parser.add_argument(
-            "-e",
-            "--email",
-            default="",
-            help=_("email (usage depends of XMPP server)"),
-        )
-        self.parser.add_argument(
-            "-H",
-            "--host",
-            default="",
-            help=_("server host (IP address or domain, default: use localhost)"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--port",
-            type=int,
-            default=0,
-            help=_("server port (default: {port})").format(
-                port=C.XMPP_C2S_PORT
-            ),
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.in_band_account_new(
-                self.args.jid,
-                self.args.password,
-                self.args.email,
-                self.args.host,
-                self.args.port,
-            )
-
-        except BridgeException as e:
-            if e.condition == 'conflict':
-                self.disp(
-                    f"The account {self.args.jid} already exists",
-                    error=True
-                )
-                self.host.quit(C.EXIT_CONFLICT)
-            else:
-                self.disp(
-                    f"can't create account on {self.args.host or 'localhost'!r} with jid "
-                    f"{self.args.jid!r} using In-Band Registration: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-
-        self.disp(_("XMPP account created"), 1)
-
-        if self.args.profile is None:
-            self.host.quit()
-
-
-        self.disp(_("creating profile"), 2)
-        try:
-            await self.host.bridge.profile_create(
-                self.args.profile,
-                self.args.password,
-                "",
-            )
-        except BridgeException as e:
-            if e.condition == 'conflict':
-                self.disp(
-                    f"The profile {self.args.profile} already exists",
-                    error=True
-                )
-                self.host.quit(C.EXIT_CONFLICT)
-            else:
-                self.disp(
-                    _("Can't create profile {profile} to associate with jid "
-                      "{jid}: {e}").format(
-                          profile=self.args.profile,
-                          jid=self.args.jid,
-                          e=e
-                      ),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-
-        self.disp(_("profile created"), 1)
-        try:
-            await self.host.bridge.profile_start_session(
-                self.args.password,
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't start profile session: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        try:
-            await self.host.bridge.param_set(
-                "JabberID",
-                self.args.jid,
-                "Connection",
-                profile_key=self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set JabberID parameter: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        try:
-            await self.host.bridge.param_set(
-                "Password",
-                self.args.password,
-                "Connection",
-                profile_key=self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set Password parameter: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.disp(
-            f"profile {self.args.profile} successfully created and associated to the new "
-            f"account", 1)
-        self.host.quit()
-
-
-class AccountModify(base.CommandBase):
-    def __init__(self, host):
-        super(AccountModify, self).__init__(
-            host, "modify", help=_("change password for XMPP account")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "password", help=_("new XMPP password")
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.in_band_password_change(
-                self.args.password,
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't change XMPP password: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class AccountDelete(base.CommandBase):
-    def __init__(self, host):
-        super(AccountDelete, self).__init__(
-            host, "delete", help=_("delete a XMPP account")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("delete account without confirmation"),
-        )
-
-    async def start(self):
-        try:
-            jid_str = await self.host.bridge.param_get_a_async(
-                "JabberID",
-                "Connection",
-                profile_key=self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get JID of the profile: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        jid_ = jid.JID(jid_str)
-        if not self.args.force:
-            message = (
-                f"You are about to delete the XMPP account with jid {jid_!r}\n"
-                f"This is the XMPP account of profile {self.profile!r}\n"
-                f"Are you sure that you want to delete this account?"
-            )
-            await self.host.confirm_or_quit(message, _("Account deletion cancelled"))
-
-        try:
-            await self.host.bridge.in_band_unregister(jid_.domain, self.args.profile)
-        except Exception as e:
-            self.disp(f"can't delete XMPP account with jid {jid_!r}: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-
-class Account(base.CommandBase):
-    subcommands = (AccountCreate, AccountModify, AccountDelete)
-
-    def __init__(self, host):
-        super(Account, self).__init__(
-            host, "account", use_profile=False, help=("XMPP account management")
-        )
--- a/sat_frontends/jp/cmd_adhoc.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,203 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.jp import xmlui_manager
-
-__commands__ = ["AdHoc"]
-
-FLAG_LOOP = "LOOP"
-MAGIC_BAREJID = "@PROFILE_BAREJID@"
-
-
-class Remote(base.CommandBase):
-    def __init__(self, host):
-        super(Remote, self).__init__(
-            host, "remote", use_verbose=True, help=_("remote control a software")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("software", type=str, help=_("software name"))
-        self.parser.add_argument(
-            "-j",
-            "--jids",
-            nargs="*",
-            default=[],
-            help=_("jids allowed to use the command"),
-        )
-        self.parser.add_argument(
-            "-g",
-            "--groups",
-            nargs="*",
-            default=[],
-            help=_("groups allowed to use the command"),
-        )
-        self.parser.add_argument(
-            "--forbidden-groups",
-            nargs="*",
-            default=[],
-            help=_("groups that are *NOT* allowed to use the command"),
-        )
-        self.parser.add_argument(
-            "--forbidden-jids",
-            nargs="*",
-            default=[],
-            help=_("jids that are *NOT* allowed to use the command"),
-        )
-        self.parser.add_argument(
-            "-l", "--loop", action="store_true", help=_("loop on the commands")
-        )
-
-    async def start(self):
-        name = self.args.software.lower()
-        flags = []
-        magics = {jid for jid in self.args.jids if jid.count("@") > 1}
-        magics.add(MAGIC_BAREJID)
-        jids = set(self.args.jids).difference(magics)
-        if self.args.loop:
-            flags.append(FLAG_LOOP)
-        try:
-            bus_name, methods = await self.host.bridge.ad_hoc_dbus_add_auto(
-                name,
-                list(jids),
-                self.args.groups,
-                magics,
-                self.args.forbidden_jids,
-                self.args.forbidden_groups,
-                flags,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create remote control: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if not bus_name:
-                self.disp(_("No bus name found"), 1)
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(_("Bus name found: [%s]" % bus_name), 1)
-                for method in methods:
-                    path, iface, command = method
-                    self.disp(
-                        _("Command found: (path:{path}, iface: {iface}) [{command}]")
-                        .format(path=path, iface=iface, command=command),
-                        1,
-                    )
-                self.host.quit()
-
-
-class Run(base.CommandBase):
-    """Run an Ad-Hoc command"""
-
-    def __init__(self, host):
-        super(Run, self).__init__(
-            host, "run", use_verbose=True, help=_("run an Ad-Hoc command")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j",
-            "--jid",
-            default="",
-            help=_("jid of the service (default: profile's server"),
-        )
-        self.parser.add_argument(
-            "-S",
-            "--submit",
-            action="append_const",
-            const=xmlui_manager.SUBMIT,
-            dest="workflow",
-            help=_("submit form/page"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="workflow",
-            metavar=("KEY", "VALUE"),
-            help=_("field value"),
-        )
-        self.parser.add_argument(
-            "node",
-            nargs="?",
-            default="",
-            help=_("node of the command (default: list commands)"),
-        )
-
-    async def start(self):
-        try:
-            xmlui_raw = await self.host.bridge.ad_hoc_run(
-                self.args.jid,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get ad-hoc commands list: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            xmlui = xmlui_manager.create(self.host, xmlui_raw)
-            workflow = self.args.workflow
-            await xmlui.show(workflow)
-            if not workflow:
-                if xmlui.type == "form":
-                    await xmlui.submit_form()
-            self.host.quit()
-
-
-class List(base.CommandBase):
-    """List Ad-Hoc commands available on a service"""
-
-    def __init__(self, host):
-        super(List, self).__init__(
-            host, "list", use_verbose=True, help=_("list Ad-Hoc commands of a service")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j",
-            "--jid",
-            default="",
-            help=_("jid of the service (default: profile's server)"),
-        )
-
-    async def start(self):
-        try:
-            xmlui_raw = await self.host.bridge.ad_hoc_list(
-                self.args.jid,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get ad-hoc commands list: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            xmlui = xmlui_manager.create(self.host, xmlui_raw)
-            await xmlui.show(read_only=True)
-            self.host.quit()
-
-
-class AdHoc(base.CommandBase):
-    subcommands = (Run, List, Remote)
-
-    def __init__(self, host):
-        super(AdHoc, self).__init__(
-            host, "ad-hoc", use_profile=False, help=_("Ad-hoc commands")
-        )
--- a/sat_frontends/jp/cmd_application.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,191 +0,0 @@
-#!/usr/bin/env python3
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from sat_frontends.jp.constants import Const as C
-
-__commands__ = ["Application"]
-
-
-class List(base.CommandBase):
-    """List available applications"""
-
-    def __init__(self, host):
-        super(List, self).__init__(
-            host, "list", use_profile=False, use_output=C.OUTPUT_LIST,
-            help=_("list available applications")
-        )
-
-    def add_parser_options(self):
-        # FIXME: "extend" would be better here, but it's only available from Python 3.8+
-        #   so we use "append" until minimum version of Python is raised.
-        self.parser.add_argument(
-            "-f",
-            "--filter",
-            dest="filters",
-            action="append",
-            choices=["available", "running"],
-            help=_("show applications with this status"),
-        )
-
-    async def start(self):
-
-        # FIXME: this is only needed because we can't use "extend" in
-        #   add_parser_options, see note there
-        if self.args.filters:
-            self.args.filters = list(set(self.args.filters))
-        else:
-            self.args.filters = ['available']
-
-        try:
-            found_apps = await self.host.bridge.applications_list(self.args.filters)
-        except Exception as e:
-            self.disp(f"can't get applications list: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(found_apps)
-            self.host.quit()
-
-
-class Start(base.CommandBase):
-    """Start an application"""
-
-    def __init__(self, host):
-        super(Start, self).__init__(
-            host, "start", use_profile=False, help=_("start an application")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "name",
-            help=_("name of the application to start"),
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.application_start(
-                self.args.name,
-                "",
-            )
-        except Exception as e:
-            self.disp(f"can't start {self.args.name}: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Stop(base.CommandBase):
-
-    def __init__(self, host):
-        super(Stop, self).__init__(
-            host, "stop", use_profile=False, help=_("stop a running application")
-        )
-
-    def add_parser_options(self):
-        id_group = self.parser.add_mutually_exclusive_group(required=True)
-        id_group.add_argument(
-            "name",
-            nargs="?",
-            help=_("name of the application to stop"),
-        )
-        id_group.add_argument(
-            "-i",
-            "--id",
-            help=_("identifier of the instance to stop"),
-        )
-
-    async def start(self):
-        try:
-            if self.args.name is not None:
-                args = [self.args.name, "name"]
-            else:
-                args = [self.args.id, "instance"]
-            await self.host.bridge.application_stop(
-                *args,
-                "",
-            )
-        except Exception as e:
-            if self.args.name is not None:
-                self.disp(
-                    f"can't stop application {self.args.name!r}: {e}", error=True)
-            else:
-                self.disp(
-                    f"can't stop application instance with id {self.args.id!r}: {e}",
-                    error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Exposed(base.CommandBase):
-
-    def __init__(self, host):
-        super(Exposed, self).__init__(
-            host, "exposed", use_profile=False, use_output=C.OUTPUT_DICT,
-            help=_("show data exposed by a running application")
-        )
-
-    def add_parser_options(self):
-        id_group = self.parser.add_mutually_exclusive_group(required=True)
-        id_group.add_argument(
-            "name",
-            nargs="?",
-            help=_("name of the application to check"),
-        )
-        id_group.add_argument(
-            "-i",
-            "--id",
-            help=_("identifier of the instance to check"),
-        )
-
-    async def start(self):
-        try:
-            if self.args.name is not None:
-                args = [self.args.name, "name"]
-            else:
-                args = [self.args.id, "instance"]
-            exposed_data_raw = await self.host.bridge.application_exposed_get(
-                *args,
-                "",
-            )
-        except Exception as e:
-            if self.args.name is not None:
-                self.disp(
-                    f"can't get values exposed from application {self.args.name!r}: {e}",
-                    error=True)
-            else:
-                self.disp(
-                    f"can't values exposed from  application instance with id {self.args.id!r}: {e}",
-                    error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            exposed_data = data_format.deserialise(exposed_data_raw)
-            await self.output(exposed_data)
-            self.host.quit()
-
-
-class Application(base.CommandBase):
-    subcommands = (List, Start, Stop, Exposed)
-
-    def __init__(self, host):
-        super(Application, self).__init__(
-            host, "application", use_profile=False, help=_("manage applications"),
-            aliases=['app'],
-        )
--- a/sat_frontends/jp/cmd_avatar.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,135 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-
-import os
-import os.path
-import asyncio
-from . import base
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.tools import config
-from libervia.backend.tools.common import data_format
-
-
-__commands__ = ["Avatar"]
-DISPLAY_CMD = ["xdg-open", "xv", "display", "gwenview", "showtell"]
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        super(Get, self).__init__(
-            host, "get", use_verbose=True, help=_("retrieve avatar of an entity")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--no-cache", action="store_true", help=_("do no use cached values")
-        )
-        self.parser.add_argument(
-            "-s", "--show", action="store_true", help=_("show avatar")
-        )
-        self.parser.add_argument("jid", nargs='?', default='', help=_("entity"))
-
-    async def show_image(self, path):
-        sat_conf = config.parse_main_conf()
-        cmd = config.config_get(sat_conf, C.CONFIG_SECTION, "image_cmd")
-        cmds = [cmd] + DISPLAY_CMD if cmd else DISPLAY_CMD
-        for cmd in cmds:
-            try:
-                process = await asyncio.create_subprocess_exec(cmd, path)
-                ret = await process.wait()
-            except OSError:
-                continue
-
-            if ret in (0, 2):
-                # we can get exit code 2 with display when stopping it with C-c
-                break
-        else:
-            # didn't worked with commands, we try our luck with webbrowser
-            # in some cases, webbrowser can actually open the associated display program.
-            # Note that this may be possibly blocking, depending on the platform and
-            # available browser
-            import webbrowser
-
-            webbrowser.open(path)
-
-    async def start(self):
-        try:
-            avatar_data_raw = await self.host.bridge.avatar_get(
-                self.args.jid,
-                not self.args.no_cache,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't retrieve avatar: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        avatar_data = data_format.deserialise(avatar_data_raw, type_check=None)
-
-        if not avatar_data:
-            self.disp(_("No avatar found."), 1)
-            self.host.quit(C.EXIT_NOT_FOUND)
-
-        avatar_path = avatar_data['path']
-
-        self.disp(avatar_path)
-        if self.args.show:
-            await self.show_image(avatar_path)
-
-        self.host.quit()
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        super(Set, self).__init__(
-            host, "set", use_verbose=True,
-            help=_("set avatar of the profile or an entity")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j", "--jid", default='', help=_("entity whose avatar must be changed"))
-        self.parser.add_argument(
-            "image_path", type=str, help=_("path to the image to upload")
-        )
-
-    async def start(self):
-        path = self.args.image_path
-        if not os.path.exists(path):
-            self.disp(_("file {path} doesn't exist!").format(path=repr(path)), error=True)
-            self.host.quit(C.EXIT_BAD_ARG)
-        path = os.path.abspath(path)
-        try:
-            await self.host.bridge.avatar_set(path, self.args.jid, self.profile)
-        except Exception as e:
-            self.disp(f"can't set avatar: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("avatar has been set"), 1)
-            self.host.quit()
-
-
-class Avatar(base.CommandBase):
-    subcommands = (Get, Set)
-
-    def __init__(self, host):
-        super(Avatar, self).__init__(
-            host, "avatar", use_profile=False, help=_("avatar uploading/retrieving")
-        )
--- a/sat_frontends/jp/cmd_blocking.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,137 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-
-import json
-import os
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from sat_frontends.jp import common
-from sat_frontends.jp.constants import Const as C
-from . import base
-
-__commands__ = ["Blocking"]
-
-
-class List(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "list",
-            use_output=C.OUTPUT_LIST,
-            help=_("list blocked entities"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            blocked_jids = await self.host.bridge.blocking_list(
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get blocked entities: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(blocked_jids)
-            self.host.quit(C.EXIT_OK)
-
-
-class Block(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "block",
-            help=_("block one or more entities"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "entities",
-            nargs="+",
-            metavar="JID",
-            help=_("JIDs of entities to block"),
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.blocking_block(
-                self.args.entities,
-                self.profile
-            )
-        except Exception as e:
-            self.disp(f"can't block entities: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class Unblock(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "unblock",
-            help=_("unblock one or more entities"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "entities",
-            nargs="+",
-            metavar="JID",
-            help=_("JIDs of entities to unblock"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_('when "all" is used, unblock all entities without confirmation'),
-        )
-
-    async def start(self):
-        if self.args.entities == ["all"]:
-            if not self.args.force:
-                await self.host.confirm_or_quit(
-                    _("All entities will be unblocked, are you sure"),
-                    _("unblock cancelled")
-                )
-            self.args.entities.clear()
-        elif self.args.force:
-            self.parser.error(_('--force is only allowed when "all" is used as target'))
-
-        try:
-            await self.host.bridge.blocking_unblock(
-                self.args.entities,
-                self.profile
-            )
-        except Exception as e:
-            self.disp(f"can't unblock entities: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class Blocking(base.CommandBase):
-    subcommands = (List, Block, Unblock)
-
-    def __init__(self, host):
-        super().__init__(
-            host, "blocking", use_profile=False, help=_("entities blocking")
-        )
--- a/sat_frontends/jp/cmd_blog.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1219 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-
-import asyncio
-from asyncio.subprocess import DEVNULL
-from configparser import NoOptionError, NoSectionError
-import json
-import os
-import os.path
-from pathlib import Path
-import re
-import subprocess
-import sys
-import tempfile
-from urllib.parse import urlparse
-
-from libervia.backend.core.i18n import _
-from libervia.backend.tools import config
-from libervia.backend.tools.common import uri
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common.ansi import ANSI as A
-from sat_frontends.jp import common
-from sat_frontends.jp.constants import Const as C
-
-from . import base, cmd_pubsub
-
-__commands__ = ["Blog"]
-
-SYNTAX_XHTML = "xhtml"
-# extensions to use with known syntaxes
-SYNTAX_EXT = {
-    # FIXME: default syntax doesn't sounds needed, there should always be a syntax set
-    #        by the plugin.
-    "": "txt",  # used when the syntax is not found
-    SYNTAX_XHTML: "xhtml",
-    "markdown": "md",
-}
-
-
-CONF_SYNTAX_EXT = "syntax_ext_dict"
-BLOG_TMP_DIR = "blog"
-# key to remove from metadata tmp file if they exist
-KEY_TO_REMOVE_METADATA = (
-    "id",
-    "content",
-    "content_xhtml",
-    "comments_node",
-    "comments_service",
-    "updated",
-)
-
-URL_REDIRECT_PREFIX = "url_redirect_"
-AIONOTIFY_INSTALL = '"pip install aionotify"'
-MB_KEYS = (
-    "id",
-    "url",
-    "atom_id",
-    "updated",
-    "published",
-    "language",
-    "comments",  # this key is used for all comments* keys
-    "tags",  # this key is used for all tag* keys
-    "author",
-    "author_jid",
-    "author_email",
-    "author_jid_verified",
-    "content",
-    "content_xhtml",
-    "title",
-    "title_xhtml",
-    "extra"
-)
-OUTPUT_OPT_NO_HEADER = "no-header"
-RE_ATTACHMENT_METADATA = re.compile(r"^(?P<key>[a-z_]+)=(?P<value>.*)")
-ALLOWER_ATTACH_MD_KEY = ("desc", "media_type", "external")
-
-
-async def guess_syntax_from_path(host, sat_conf, path):
-    """Return syntax guessed according to filename extension
-
-    @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
-    @param path(str): path to the content file
-    @return(unicode): syntax to use
-    """
-    # we first try to guess syntax with extension
-    ext = os.path.splitext(path)[1][1:]  # we get extension without the '.'
-    if ext:
-        for k, v in SYNTAX_EXT.items():
-            if k and ext == v:
-                return k
-
-                # if not found, we use current syntax
-    return await host.bridge.param_get_a("Syntax", "Composition", "value", host.profile)
-
-
-class BlogPublishCommon:
-    """handle common option for publising commands (Set and Edit)"""
-
-    async def get_current_syntax(self):
-        """Retrieve current_syntax
-
-        Use default syntax if --syntax has not been used, else check given syntax.
-        Will set self.default_syntax_used to True if default syntax has been used
-        """
-        if self.args.syntax is None:
-            self.default_syntax_used = True
-            return await self.host.bridge.param_get_a(
-                "Syntax", "Composition", "value", self.profile
-            )
-        else:
-            self.default_syntax_used = False
-            try:
-                syntax = await self.host.bridge.syntax_get(self.args.syntax)
-                self.current_syntax = self.args.syntax = syntax
-            except Exception as e:
-                if e.classname == "NotFound":
-                    self.parser.error(
-                        _("unknown syntax requested ({syntax})").format(
-                            syntax=self.args.syntax
-                        )
-                    )
-                else:
-                    raise e
-        return self.args.syntax
-
-    def add_parser_options(self):
-        self.parser.add_argument("-T", "--title", help=_("title of the item"))
-        self.parser.add_argument(
-            "-t",
-            "--tag",
-            action="append",
-            help=_("tag (category) of your item"),
-        )
-        self.parser.add_argument(
-            "-l",
-            "--language",
-            help=_("language of the item (ISO 639 code)"),
-        )
-
-        self.parser.add_argument(
-            "-a",
-            "--attachment",
-            dest="attachments",
-            nargs="+",
-            help=_(
-                "attachment in the form URL [metadata_name=value]"
-            )
-        )
-
-        comments_group = self.parser.add_mutually_exclusive_group()
-        comments_group.add_argument(
-            "-C",
-            "--comments",
-            action="store_const",
-            const=True,
-            dest="comments",
-            help=_(
-                "enable comments (default: comments not enabled except if they "
-                "already exist)"
-            ),
-        )
-        comments_group.add_argument(
-            "--no-comments",
-            action="store_const",
-            const=False,
-            dest="comments",
-            help=_("disable comments (will remove comments node if it exist)"),
-        )
-
-        self.parser.add_argument(
-            "-S",
-            "--syntax",
-            help=_("syntax to use (default: get profile's default syntax)"),
-        )
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("end-to-end encrypt the blog post")
-        )
-        self.parser.add_argument(
-            "--encrypt-for",
-            metavar="JID",
-            action="append",
-            help=_("encrypt a single item for")
-        )
-        self.parser.add_argument(
-            "-X",
-            "--sign",
-            action="store_true",
-            help=_("cryptographically sign the blog post")
-        )
-
-    async def set_mb_data_content(self, content, mb_data):
-        if self.default_syntax_used:
-            # default syntax has been used
-            mb_data["content_rich"] = content
-        elif self.current_syntax == SYNTAX_XHTML:
-            mb_data["content_xhtml"] = content
-        else:
-            mb_data["content_xhtml"] = await self.host.bridge.syntax_convert(
-                content, self.current_syntax, SYNTAX_XHTML, False, self.profile
-            )
-
-    def handle_attachments(self, mb_data: dict) -> None:
-        """Check, validate and add attachments to mb_data"""
-        if self.args.attachments:
-            attachments = []
-            attachment = {}
-            for arg in self.args.attachments:
-                m = RE_ATTACHMENT_METADATA.match(arg)
-                if m is None:
-                    # we should have an URL
-                    url_parsed = urlparse(arg)
-                    if url_parsed.scheme not in ("http", "https"):
-                        self.parser.error(
-                            "invalid URL in --attachment (only http(s) scheme is "
-                            f" accepted): {arg}"
-                        )
-                    if attachment:
-                        # if we hae a new URL, we have a new attachment
-                        attachments.append(attachment)
-                        attachment = {}
-                    attachment["url"] = arg
-                else:
-                    # we should have a metadata
-                    if "url" not in attachment:
-                        self.parser.error(
-                            "you must to specify an URL before any metadata in "
-                            "--attachment"
-                        )
-                    key = m.group("key")
-                    if key not in ALLOWER_ATTACH_MD_KEY:
-                        self.parser.error(
-                            f"invalid metadata key in --attachment: {key!r}"
-                        )
-                    value = m.group("value").strip()
-                    if key == "external":
-                        if not value:
-                            value=True
-                        else:
-                            value = C.bool(value)
-                    attachment[key] = value
-            if attachment:
-                attachments.append(attachment)
-            if attachments:
-                mb_data.setdefault("extra", {})["attachments"] = attachments
-
-    def set_mb_data_from_args(self, mb_data):
-        """set microblog metadata according to command line options
-
-        if metadata already exist, it will be overwritten
-        """
-        if self.args.comments is not None:
-            mb_data["allow_comments"] = self.args.comments
-        if self.args.tag:
-            mb_data["tags"] = self.args.tag
-        if self.args.title is not None:
-            mb_data["title"] = self.args.title
-        if self.args.language is not None:
-            mb_data["language"] = self.args.language
-        if self.args.encrypt:
-            mb_data["encrypted"] = True
-        if self.args.sign:
-            mb_data["signed"] = True
-        if self.args.encrypt_for:
-            mb_data["encrypted_for"] = {"targets": self.args.encrypt_for}
-        self.handle_attachments(mb_data)
-
-
-class Set(base.CommandBase, BlogPublishCommon):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("publish a new blog item or update an existing one"),
-        )
-        BlogPublishCommon.__init__(self)
-
-    def add_parser_options(self):
-        BlogPublishCommon.add_parser_options(self)
-
-    async def start(self):
-        self.current_syntax = await self.get_current_syntax()
-        self.pubsub_item = self.args.item
-        mb_data = {}
-        self.set_mb_data_from_args(mb_data)
-        if self.pubsub_item:
-            mb_data["id"] = self.pubsub_item
-        content = sys.stdin.read()
-        await self.set_mb_data_content(content, mb_data)
-
-        try:
-            item_id = await self.host.bridge.mb_send(
-                self.args.service,
-                self.args.node,
-                data_format.serialise(mb_data),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't send item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(f"Item published with ID {item_id}")
-            self.host.quit(C.EXIT_OK)
-
-
-class Get(base.CommandBase):
-    TEMPLATE = "blog/articles.html"
-
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output, "fancy": self.fancy_output}
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_verbose=True,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS, C.CACHE},
-            use_output=C.OUTPUT_COMPLEX,
-            extra_outputs=extra_outputs,
-            help=_("get blog item(s)"),
-        )
-
-    def add_parser_options(self):
-        #  TODO: a key(s) argument to select keys to display
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            action="append",
-            dest="keys",
-            help=_("microblog data key(s) to display (default: depend of verbosity)"),
-        )
-        # TODO: add MAM filters
-
-    def template_data_mapping(self, data):
-        items, blog_items = data
-        blog_items["items"] = items
-        return {"blog_items": blog_items}
-
-    def format_comments(self, item, keys):
-        lines = []
-        for data in item.get("comments", []):
-            lines.append(data["uri"])
-            for k in ("node", "service"):
-                if OUTPUT_OPT_NO_HEADER in self.args.output_opts:
-                    header = ""
-                else:
-                    header = f"{C.A_HEADER}comments_{k}: {A.RESET}"
-                lines.append(header + data[k])
-        return "\n".join(lines)
-
-    def format_tags(self, item, keys):
-        tags = item.pop("tags", [])
-        return ", ".join(tags)
-
-    def format_updated(self, item, keys):
-        return common.format_time(item["updated"])
-
-    def format_published(self, item, keys):
-        return common.format_time(item["published"])
-
-    def format_url(self, item, keys):
-        return uri.build_xmpp_uri(
-            "pubsub",
-            subtype="microblog",
-            path=self.metadata["service"],
-            node=self.metadata["node"],
-            item=item["id"],
-        )
-
-    def get_keys(self):
-        """return keys to display according to verbosity or explicit key request"""
-        verbosity = self.args.verbose
-        if self.args.keys:
-            if not set(MB_KEYS).issuperset(self.args.keys):
-                self.disp(
-                    "following keys are invalid: {invalid}.\n"
-                    "Valid keys are: {valid}.".format(
-                        invalid=", ".join(set(self.args.keys).difference(MB_KEYS)),
-                        valid=", ".join(sorted(MB_KEYS)),
-                    ),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-            return self.args.keys
-        else:
-            if verbosity == 0:
-                return ("title", "content")
-            elif verbosity == 1:
-                return (
-                    "title",
-                    "tags",
-                    "author",
-                    "author_jid",
-                    "author_email",
-                    "author_jid_verified",
-                    "published",
-                    "updated",
-                    "content",
-                )
-            else:
-                return MB_KEYS
-
-    def default_output(self, data):
-        """simple key/value output"""
-        items, self.metadata = data
-        keys = self.get_keys()
-
-        #  k_cb use format_[key] methods for complex formattings
-        k_cb = {}
-        for k in keys:
-            try:
-                callback = getattr(self, "format_" + k)
-            except AttributeError:
-                pass
-            else:
-                k_cb[k] = callback
-        for idx, item in enumerate(items):
-            for k in keys:
-                if k not in item and k not in k_cb:
-                    continue
-                if OUTPUT_OPT_NO_HEADER in self.args.output_opts:
-                    header = ""
-                else:
-                    header = "{k_fmt}{key}:{k_fmt_e} {sep}".format(
-                        k_fmt=C.A_HEADER,
-                        key=k,
-                        k_fmt_e=A.RESET,
-                        sep="\n" if "content" in k else "",
-                    )
-                value = k_cb[k](item, keys) if k in k_cb else item[k]
-                if isinstance(value, bool):
-                    value = str(value).lower()
-                elif isinstance(value, dict):
-                    value = repr(value)
-                self.disp(header + (value or ""))
-                # we want a separation line after each item but the last one
-            if idx < len(items) - 1:
-                print("")
-
-    def fancy_output(self, data):
-        """display blog is a nice to read way
-
-        this output doesn't use keys filter
-        """
-        # thanks to http://stackoverflow.com/a/943921
-        rows, columns = list(map(int, os.popen("stty size", "r").read().split()))
-        items, metadata = data
-        verbosity = self.args.verbose
-        sep = A.color(A.FG_BLUE, columns * "▬")
-        if items:
-            print(("\n" + sep + "\n"))
-
-        for idx, item in enumerate(items):
-            title = item.get("title")
-            if verbosity > 0:
-                author = item["author"]
-                published, updated = item["published"], item.get("updated")
-            else:
-                author = published = updated = None
-            if verbosity > 1:
-                tags = item.pop("tags", [])
-            else:
-                tags = None
-            content = item.get("content")
-
-            if title:
-                print((A.color(A.BOLD, A.FG_CYAN, item["title"])))
-            meta = []
-            if author:
-                meta.append(A.color(A.FG_YELLOW, author))
-            if published:
-                meta.append(A.color(A.FG_YELLOW, "on ", common.format_time(published)))
-            if updated != published:
-                meta.append(
-                    A.color(A.FG_YELLOW, "(updated on ", common.format_time(updated), ")")
-                )
-            print((" ".join(meta)))
-            if tags:
-                print((A.color(A.FG_MAGENTA, ", ".join(tags))))
-            if (title or tags) and content:
-                print("")
-            if content:
-                self.disp(content)
-
-            print(("\n" + sep + "\n"))
-
-    async def start(self):
-        try:
-            mb_data = data_format.deserialise(
-                await self.host.bridge.mb_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    self.get_pubsub_extra(),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't get blog items: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            items = mb_data.pop("items")
-            await self.output((items, mb_data))
-            self.host.quit(C.EXIT_OK)
-
-
-class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "edit",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            use_draft=True,
-            use_verbose=True,
-            help=_("edit an existing or new blog post"),
-        )
-        BlogPublishCommon.__init__(self)
-        common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
-
-    def add_parser_options(self):
-        BlogPublishCommon.add_parser_options(self)
-        self.parser.add_argument(
-            "-P",
-            "--preview",
-            action="store_true",
-            help=_("launch a blog preview in parallel"),
-        )
-        self.parser.add_argument(
-            "--no-publish",
-            action="store_true",
-            help=_('add "publish: False" to metadata'),
-        )
-
-    def build_metadata_file(self, content_file_path, mb_data=None):
-        """Build a metadata file using json
-
-        The file is named after content_file_path, with extension replaced by
-        _metadata.json
-        @param content_file_path(str): path to the temporary file which will contain the
-            body
-        @param mb_data(dict, None): microblog metadata (for existing items)
-        @return (tuple[dict, Path]): merged metadata put originaly in metadata file
-            and path to temporary metadata file
-        """
-        # we first construct metadata from edited item ones and CLI argumments
-        # or re-use the existing one if it exists
-        meta_file_path = content_file_path.with_name(
-            content_file_path.stem + common.METADATA_SUFF
-        )
-        if meta_file_path.exists():
-            self.disp("Metadata file already exists, we re-use it")
-            try:
-                with meta_file_path.open("rb") as f:
-                    mb_data = json.load(f)
-            except (OSError, IOError, ValueError) as e:
-                self.disp(
-                    f"Can't read existing metadata file at {meta_file_path}, "
-                    f"aborting: {e}",
-                    error=True,
-                )
-                self.host.quit(1)
-        else:
-            mb_data = {} if mb_data is None else mb_data.copy()
-
-            # in all cases, we want to remove unwanted keys
-        for key in KEY_TO_REMOVE_METADATA:
-            try:
-                del mb_data[key]
-            except KeyError:
-                pass
-                # and override metadata with command-line arguments
-        self.set_mb_data_from_args(mb_data)
-
-        if self.args.no_publish:
-            mb_data["publish"] = False
-
-            # then we create the file and write metadata there, as JSON dict
-            # XXX: if we port jp one day on Windows, O_BINARY may need to be added here
-        with os.fdopen(
-            os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), "w+b"
-        ) as f:
-            # we need to use an intermediate unicode buffer to write to the file
-            # unicode without escaping characters
-            unicode_dump = json.dumps(
-                mb_data,
-                ensure_ascii=False,
-                indent=4,
-                separators=(",", ": "),
-                sort_keys=True,
-            )
-            f.write(unicode_dump.encode("utf-8"))
-
-        return mb_data, meta_file_path
-
-    async def edit(self, content_file_path, content_file_obj, mb_data=None):
-        """Edit the file contening the content using editor, and publish it"""
-        # we first create metadata file
-        meta_ori, meta_file_path = self.build_metadata_file(content_file_path, mb_data)
-
-        coroutines = []
-
-        # do we need a preview ?
-        if self.args.preview:
-            self.disp("Preview requested, launching it", 1)
-            # we redirect outputs to /dev/null to avoid console pollution in editor
-            # if user wants to see messages, (s)he can call "blog preview" directly
-            coroutines.append(
-                asyncio.create_subprocess_exec(
-                    sys.argv[0],
-                    "blog",
-                    "preview",
-                    "--inotify",
-                    "true",
-                    "-p",
-                    self.profile,
-                    str(content_file_path),
-                    stdout=DEVNULL,
-                    stderr=DEVNULL,
-                )
-            )
-
-            # we launch editor
-        coroutines.append(
-            self.run_editor(
-                "blog_editor_args",
-                content_file_path,
-                content_file_obj,
-                meta_file_path=meta_file_path,
-                meta_ori=meta_ori,
-            )
-        )
-
-        await asyncio.gather(*coroutines)
-
-    async def publish(self, content, mb_data):
-        await self.set_mb_data_content(content, mb_data)
-
-        if self.pubsub_item:
-            mb_data["id"] = self.pubsub_item
-
-        mb_data = data_format.serialise(mb_data)
-
-        await self.host.bridge.mb_send(
-            self.pubsub_service, self.pubsub_node, mb_data, self.profile
-        )
-        self.disp("Blog item published")
-
-    def get_tmp_suff(self):
-        # we get current syntax to determine file extension
-        return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[""])
-
-    async def get_item_data(self, service, node, item):
-        items = [item] if item else []
-
-        mb_data = data_format.deserialise(
-            await self.host.bridge.mb_get(
-                service, node, 1, items, data_format.serialise({}), self.profile
-            )
-        )
-        item = mb_data["items"][0]
-
-        try:
-            content = item["content_xhtml"]
-        except KeyError:
-            content = item["content"]
-            if content:
-                content = await self.host.bridge.syntax_convert(
-                    content, "text", SYNTAX_XHTML, False, self.profile
-                )
-
-        if content and self.current_syntax != SYNTAX_XHTML:
-            content = await self.host.bridge.syntax_convert(
-                content, SYNTAX_XHTML, self.current_syntax, False, self.profile
-            )
-
-        if content and self.current_syntax == SYNTAX_XHTML:
-            content = content.strip()
-            if not content.startswith("<div>"):
-                content = "<div>" + content + "</div>"
-            try:
-                from lxml import etree
-            except ImportError:
-                self.disp(_("You need lxml to edit pretty XHTML"))
-            else:
-                parser = etree.XMLParser(remove_blank_text=True)
-                root = etree.fromstring(content, parser)
-                content = etree.tostring(root, encoding=str, pretty_print=True)
-
-        return content, item, item["id"]
-
-    async def start(self):
-        # if there are user defined extension, we use them
-        SYNTAX_EXT.update(
-            config.config_get(self.sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
-        )
-        self.current_syntax = await self.get_current_syntax()
-
-        (
-            self.pubsub_service,
-            self.pubsub_node,
-            self.pubsub_item,
-            content_file_path,
-            content_file_obj,
-            mb_data,
-        ) = await self.get_item_path()
-
-        await self.edit(content_file_path, content_file_obj, mb_data=mb_data)
-        self.host.quit()
-
-
-class Rename(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "rename",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("rename an blog item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("new_id", help=_("new item id to use"))
-
-    async def start(self):
-        try:
-            await self.host.bridge.mb_rename(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.new_id,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't rename item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("Item renamed")
-            self.host.quit(C.EXIT_OK)
-
-
-class Repeat(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "repeat",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("repeat (re-publish) a blog item"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            repeat_id = await self.host.bridge.mb_repeat(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't repeat item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if repeat_id:
-                self.disp(f"Item repeated at ID {str(repeat_id)!r}")
-            else:
-                self.disp("Item repeated")
-            self.host.quit(C.EXIT_OK)
-
-
-class Preview(base.CommandBase, common.BaseEdit):
-    # TODO: need to be rewritten with template output
-
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "preview", use_verbose=True, help=_("preview a blog content")
-        )
-        common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--inotify",
-            type=str,
-            choices=("auto", "true", "false"),
-            default="auto",
-            help=_("use inotify to handle preview"),
-        )
-        self.parser.add_argument(
-            "file",
-            nargs="?",
-            default="current",
-            help=_("path to the content file"),
-        )
-
-    async def show_preview(self):
-        # we implement show_preview here so we don't have to import webbrowser and urllib
-        # when preview is not used
-        url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
-        self.webbrowser.open_new_tab(url)
-
-    async def _launch_preview_ext(self, cmd_line, opt_name):
-        url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path))
-        args = common.parse_args(
-            self.host, cmd_line, url=url, preview_file=self.preview_file_path
-        )
-        if not args:
-            self.disp(
-                'Couln\'t find command in "{name}", abording'.format(name=opt_name),
-                error=True,
-            )
-            self.host.quit(1)
-        subprocess.Popen(args)
-
-    async def open_preview_ext(self):
-        await self._launch_preview_ext(self.open_cb_cmd, "blog_preview_open_cmd")
-
-    async def update_preview_ext(self):
-        await self._launch_preview_ext(self.update_cb_cmd, "blog_preview_update_cmd")
-
-    async def update_content(self):
-        with self.content_file_path.open("rb") as f:
-            content = f.read().decode("utf-8-sig")
-            if content and self.syntax != SYNTAX_XHTML:
-                # we use safe=True because we want to have a preview as close as possible
-                # to what the people will see
-                content = await self.host.bridge.syntax_convert(
-                    content, self.syntax, SYNTAX_XHTML, True, self.profile
-                )
-
-        xhtml = (
-            f'<html xmlns="http://www.w3.org/1999/xhtml">'
-            f'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />'
-            f"</head>"
-            f"<body>{content}</body>"
-            f"</html>"
-        )
-
-        with open(self.preview_file_path, "wb") as f:
-            f.write(xhtml.encode("utf-8"))
-
-    async def start(self):
-        import webbrowser
-        import urllib.request, urllib.parse, urllib.error
-
-        self.webbrowser, self.urllib = webbrowser, urllib
-
-        if self.args.inotify != "false":
-            try:
-                import aionotify
-
-            except ImportError:
-                if self.args.inotify == "auto":
-                    aionotify = None
-                    self.disp(
-                        f"aionotify module not found, deactivating feature. You can "
-                        f"install it with {AIONOTIFY_INSTALL}"
-                    )
-                else:
-                    self.disp(
-                        f"aioinotify not found, can't activate the feature! Please "
-                        f"install it with {AIONOTIFY_INSTALL}",
-                        error=True,
-                    )
-                    self.host.quit(1)
-        else:
-            aionotify = None
-
-        sat_conf = self.sat_conf
-        SYNTAX_EXT.update(
-            config.config_get(sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {})
-        )
-
-        try:
-            self.open_cb_cmd = config.config_get(
-                sat_conf, C.CONFIG_SECTION, "blog_preview_open_cmd", Exception
-            )
-        except (NoOptionError, NoSectionError):
-            self.open_cb_cmd = None
-            open_cb = self.show_preview
-        else:
-            open_cb = self.open_preview_ext
-
-        self.update_cb_cmd = config.config_get(
-            sat_conf, C.CONFIG_SECTION, "blog_preview_update_cmd", self.open_cb_cmd
-        )
-        if self.update_cb_cmd is None:
-            update_cb = self.show_preview
-        else:
-            update_cb = self.update_preview_ext
-
-            # which file do we need to edit?
-        if self.args.file == "current":
-            self.content_file_path = self.get_current_file(self.profile)
-        else:
-            try:
-                self.content_file_path = Path(self.args.file).resolve(strict=True)
-            except FileNotFoundError:
-                self.disp(_('File "{file}" doesn\'t exist!').format(file=self.args.file))
-                self.host.quit(C.EXIT_NOT_FOUND)
-
-        self.syntax = await guess_syntax_from_path(
-            self.host, sat_conf, self.content_file_path
-        )
-
-        # at this point the syntax is converted, we can display the preview
-        preview_file = tempfile.NamedTemporaryFile(suffix=".xhtml", delete=False)
-        self.preview_file_path = preview_file.name
-        preview_file.close()
-        await self.update_content()
-
-        if aionotify is None:
-            # XXX: we don't delete file automatically because browser needs it
-            #      (and webbrowser.open can return before it is read)
-            self.disp(
-                f"temporary file created at {self.preview_file_path}\nthis file will NOT "
-                f"BE DELETED AUTOMATICALLY, please delete it yourself when you have "
-                f"finished"
-            )
-            await open_cb()
-        else:
-            await open_cb()
-            watcher = aionotify.Watcher()
-            watcher_kwargs = {
-                # Watcher don't accept Path so we convert to string
-                "path": str(self.content_file_path),
-                "alias": "content_file",
-                "flags": aionotify.Flags.CLOSE_WRITE
-                | aionotify.Flags.DELETE_SELF
-                | aionotify.Flags.MOVE_SELF,
-            }
-            watcher.watch(**watcher_kwargs)
-
-            loop = asyncio.get_event_loop()
-            await watcher.setup(loop)
-
-            try:
-                while True:
-                    event = await watcher.get_event()
-                    self.disp("Content updated", 1)
-                    if event.flags & (
-                        aionotify.Flags.DELETE_SELF | aionotify.Flags.MOVE_SELF
-                    ):
-                        self.disp(
-                            "DELETE/MOVE event catched, changing the watch",
-                            2,
-                        )
-                        try:
-                            watcher.unwatch("content_file")
-                        except IOError as e:
-                            self.disp(
-                                f"Can't remove the watch: {e}",
-                                2,
-                            )
-                        watcher = aionotify.Watcher()
-                        watcher.watch(**watcher_kwargs)
-                        try:
-                            await watcher.setup(loop)
-                        except OSError:
-                            # if the new file is not here yet we can have an error
-                            # as a workaround, we do a little rest and try again
-                            await asyncio.sleep(1)
-                            await watcher.setup(loop)
-                    await self.update_content()
-                    await update_cb()
-            except FileNotFoundError:
-                self.disp("The file seems to have been deleted.", error=True)
-                self.host.quit(C.EXIT_NOT_FOUND)
-            finally:
-                os.unlink(self.preview_file_path)
-                try:
-                    watcher.unwatch("content_file")
-                except IOError as e:
-                    self.disp(
-                        f"Can't remove the watch: {e}",
-                        2,
-                    )
-
-
-class Import(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "import",
-            use_pubsub=True,
-            use_progress=True,
-            help=_("import an external blog"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "importer",
-            nargs="?",
-            help=_("importer name, nothing to display importers list"),
-        )
-        self.parser.add_argument("--host", help=_("original blog host"))
-        self.parser.add_argument(
-            "--no-images-upload",
-            action="store_true",
-            help=_("do *NOT* upload images (default: do upload images)"),
-        )
-        self.parser.add_argument(
-            "--upload-ignore-host",
-            help=_("do not upload images from this host (default: upload all images)"),
-        )
-        self.parser.add_argument(
-            "--ignore-tls-errors",
-            action="store_true",
-            help=_("ignore invalide TLS certificate for uploads"),
-        )
-        self.parser.add_argument(
-            "-o",
-            "--option",
-            action="append",
-            nargs=2,
-            default=[],
-            metavar=("NAME", "VALUE"),
-            help=_("importer specific options (see importer description)"),
-        )
-        self.parser.add_argument(
-            "location",
-            nargs="?",
-            help=_(
-                "importer data location (see importer description), nothing to show "
-                "importer description"
-            ),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("Blog upload started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("Blog uploaded successfully"), 2)
-        redirections = {
-            k[len(URL_REDIRECT_PREFIX) :]: v
-            for k, v in metadata.items()
-            if k.startswith(URL_REDIRECT_PREFIX)
-        }
-        if redirections:
-            conf = "\n".join(
-                [
-                    "url_redirections_dict = {}".format(
-                        # we need to add ' ' before each new line
-                        # and to double each '%' for ConfigParser
-                        "\n ".join(
-                            json.dumps(redirections, indent=1, separators=(",", ": "))
-                            .replace("%", "%%")
-                            .split("\n")
-                        )
-                    ),
-                ]
-            )
-            self.disp(
-                _(
-                    "\nTo redirect old URLs to new ones, put the following lines in your"
-                    " sat.conf file, in [libervia] section:\n\n{conf}"
-                ).format(conf=conf)
-            )
-
-    async def on_progress_error(self, error_msg):
-        self.disp(
-            _("Error while uploading blog: {error_msg}").format(error_msg=error_msg),
-            error=True,
-        )
-
-    async def start(self):
-        if self.args.location is None:
-            for name in ("option", "service", "no_images_upload"):
-                if getattr(self.args, name):
-                    self.parser.error(
-                        _(
-                            "{name} argument can't be used without location argument"
-                        ).format(name=name)
-                    )
-            if self.args.importer is None:
-                self.disp(
-                    "\n".join(
-                        [
-                            f"{name}: {desc}"
-                            for name, desc in await self.host.bridge.blogImportList()
-                        ]
-                    )
-                )
-            else:
-                try:
-                    short_desc, long_desc = await self.host.bridge.blogImportDesc(
-                        self.args.importer
-                    )
-                except Exception as e:
-                    msg = [l for l in str(e).split("\n") if l][
-                        -1
-                    ]  # we only keep the last line
-                    self.disp(msg)
-                    self.host.quit(1)
-                else:
-                    self.disp(f"{self.args.importer}: {short_desc}\n\n{long_desc}")
-            self.host.quit()
-        else:
-            # we have a location, an import is requested
-            options = {key: value for key, value in self.args.option}
-            if self.args.host:
-                options["host"] = self.args.host
-            if self.args.ignore_tls_errors:
-                options["ignore_tls_errors"] = C.BOOL_TRUE
-            if self.args.no_images_upload:
-                options["upload_images"] = C.BOOL_FALSE
-                if self.args.upload_ignore_host:
-                    self.parser.error(
-                        "upload-ignore-host option can't be used when no-images-upload "
-                        "is set"
-                    )
-            elif self.args.upload_ignore_host:
-                options["upload_ignore_host"] = self.args.upload_ignore_host
-
-            try:
-                progress_id = await self.host.bridge.blogImport(
-                    self.args.importer,
-                    self.args.location,
-                    options,
-                    self.args.service,
-                    self.args.node,
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(
-                    _("Error while trying to import a blog: {e}").format(e=e),
-                    error=True,
-                )
-                self.host.quit(1)
-            else:
-                await self.set_progress_id(progress_id)
-
-
-class AttachmentGet(cmd_pubsub.AttachmentGet):
-
-    def __init__(self, host):
-        super().__init__(host)
-        self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
-
-
-    async def start(self):
-        if not self.args.node:
-            namespaces = await self.host.bridge.namespaces_get()
-            try:
-                ns_microblog = namespaces["microblog"]
-            except KeyError:
-                self.disp("XEP-0277 plugin is not loaded", error=True)
-                self.host.quit(C.EXIT_MISSING_FEATURE)
-            else:
-                self.args.node = ns_microblog
-        return await super().start()
-
-
-class AttachmentSet(cmd_pubsub.AttachmentSet):
-
-    def __init__(self, host):
-        super().__init__(host)
-        self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM})
-
-    async def start(self):
-        if not self.args.node:
-            namespaces = await self.host.bridge.namespaces_get()
-            try:
-                ns_microblog = namespaces["microblog"]
-            except KeyError:
-                self.disp("XEP-0277 plugin is not loaded", error=True)
-                self.host.quit(C.EXIT_MISSING_FEATURE)
-            else:
-                self.args.node = ns_microblog
-        return await super().start()
-
-
-class Attachments(base.CommandBase):
-    subcommands = (AttachmentGet, AttachmentSet)
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "attachments",
-            use_profile=False,
-            help=_("set or retrieve blog attachments"),
-        )
-
-
-class Blog(base.CommandBase):
-    subcommands = (Set, Get, Edit, Rename, Repeat, Preview, Import, Attachments)
-
-    def __init__(self, host):
-        super(Blog, self).__init__(
-            host, "blog", use_profile=False, help=_("blog/microblog management")
-        )
--- a/sat_frontends/jp/cmd_bookmarks.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,175 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-
-__commands__ = ["Bookmarks"]
-
-STORAGE_LOCATIONS = ("local", "private", "pubsub")
-TYPES = ("muc", "url")
-
-
-class BookmarksCommon(base.CommandBase):
-    """Class used to group common options of bookmarks subcommands"""
-
-    def add_parser_options(self, location_default="all"):
-        self.parser.add_argument(
-            "-l",
-            "--location",
-            type=str,
-            choices=(location_default,) + STORAGE_LOCATIONS,
-            default=location_default,
-            help=_("storage location (default: %(default)s)"),
-        )
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            type=str,
-            choices=TYPES,
-            default=TYPES[0],
-            help=_("bookmarks type (default: %(default)s)"),
-        )
-
-
-class BookmarksList(BookmarksCommon):
-    def __init__(self, host):
-        super(BookmarksList, self).__init__(host, "list", help=_("list bookmarks"))
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.bookmarks_list(
-                self.args.type, self.args.location, self.host.profile
-            )
-        except Exception as e:
-            self.disp(f"can't get bookmarks list: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        mess = []
-        for location in STORAGE_LOCATIONS:
-            if not data[location]:
-                continue
-            loc_mess = []
-            loc_mess.append(f"{location}:")
-            book_mess = []
-            for book_link, book_data in list(data[location].items()):
-                name = book_data.get("name")
-                autojoin = book_data.get("autojoin", "false") == "true"
-                nick = book_data.get("nick")
-                book_mess.append(
-                    "\t%s[%s%s]%s"
-                    % (
-                        (name + " ") if name else "",
-                        book_link,
-                        " (%s)" % nick if nick else "",
-                        " (*)" if autojoin else "",
-                    )
-                )
-            loc_mess.append("\n".join(book_mess))
-            mess.append("\n".join(loc_mess))
-
-        print("\n\n".join(mess))
-        self.host.quit()
-
-
-class BookmarksRemove(BookmarksCommon):
-    def __init__(self, host):
-        super(BookmarksRemove, self).__init__(host, "remove", help=_("remove a bookmark"))
-
-    def add_parser_options(self):
-        super(BookmarksRemove, self).add_parser_options()
-        self.parser.add_argument(
-            "bookmark", help=_("jid (for muc bookmark) or url of to remove")
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("delete bookmark without confirmation"),
-        )
-
-    async def start(self):
-        if not self.args.force:
-            await self.host.confirm_or_quit(_("Are you sure to delete this bookmark?"))
-
-        try:
-            await self.host.bridge.bookmarks_remove(
-                self.args.type, self.args.bookmark, self.args.location, self.host.profile
-            )
-        except Exception as e:
-            self.disp(_("can't delete bookmark: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("bookmark deleted"))
-            self.host.quit()
-
-
-class BookmarksAdd(BookmarksCommon):
-    def __init__(self, host):
-        super(BookmarksAdd, self).__init__(host, "add", help=_("add a bookmark"))
-
-    def add_parser_options(self):
-        super(BookmarksAdd, self).add_parser_options(location_default="auto")
-        self.parser.add_argument(
-            "bookmark", help=_("jid (for muc bookmark) or url of to remove")
-        )
-        self.parser.add_argument("-n", "--name", help=_("bookmark name"))
-        muc_group = self.parser.add_argument_group(_("MUC specific options"))
-        muc_group.add_argument("-N", "--nick", help=_("nickname"))
-        muc_group.add_argument(
-            "-a",
-            "--autojoin",
-            action="store_true",
-            help=_("join room on profile connection"),
-        )
-
-    async def start(self):
-        if self.args.type == "url" and (self.args.autojoin or self.args.nick is not None):
-            self.parser.error(_("You can't use --autojoin or --nick with --type url"))
-        data = {}
-        if self.args.autojoin:
-            data["autojoin"] = "true"
-        if self.args.nick is not None:
-            data["nick"] = self.args.nick
-        if self.args.name is not None:
-            data["name"] = self.args.name
-        try:
-            await self.host.bridge.bookmarks_add(
-                self.args.type,
-                self.args.bookmark,
-                data,
-                self.args.location,
-                self.host.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't add bookmark: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("bookmark successfully added"))
-            self.host.quit()
-
-
-class Bookmarks(base.CommandBase):
-    subcommands = (BookmarksList, BookmarksRemove, BookmarksAdd)
-
-    def __init__(self, host):
-        super(Bookmarks, self).__init__(
-            host, "bookmarks", use_profile=False, help=_("manage bookmarks")
-        )
--- a/sat_frontends/jp/cmd_debug.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,228 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.tools.common.ansi import ANSI as A
-import json
-
-__commands__ = ["Debug"]
-
-
-class BridgeCommon(object):
-    def eval_args(self):
-        if self.args.arg:
-            try:
-                return eval("[{}]".format(",".join(self.args.arg)))
-            except SyntaxError as e:
-                self.disp(
-                    "Can't evaluate arguments: {mess}\n{text}\n{offset}^".format(
-                        mess=e, text=e.text, offset=" " * (e.offset - 1)
-                    ),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-        else:
-            return []
-
-
-class Method(base.CommandBase, BridgeCommon):
-    def __init__(self, host):
-        base.CommandBase.__init__(self, host, "method", help=_("call a bridge method"))
-        BridgeCommon.__init__(self)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "method", type=str, help=_("name of the method to execute")
-        )
-        self.parser.add_argument("arg", nargs="*", help=_("argument of the method"))
-
-    async def start(self):
-        method = getattr(self.host.bridge, self.args.method)
-        import inspect
-
-        argspec = inspect.getargspec(method)
-
-        kwargs = {}
-        if "profile_key" in argspec.args:
-            kwargs["profile_key"] = self.profile
-        elif "profile" in argspec.args:
-            kwargs["profile"] = self.profile
-
-        args = self.eval_args()
-
-        try:
-            ret = await method(
-                *args,
-                **kwargs,
-            )
-        except Exception as e:
-            self.disp(
-                _("Error while executing {method}: {e}").format(
-                    method=self.args.method, e=e
-                ),
-                error=True,
-            )
-            self.host.quit(C.EXIT_ERROR)
-        else:
-            if ret is not None:
-                self.disp(str(ret))
-            self.host.quit()
-
-
-class Signal(base.CommandBase, BridgeCommon):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "signal", help=_("send a fake signal from backend")
-        )
-        BridgeCommon.__init__(self)
-
-    def add_parser_options(self):
-        self.parser.add_argument("signal", type=str, help=_("name of the signal to send"))
-        self.parser.add_argument("arg", nargs="*", help=_("argument of the signal"))
-
-    async def start(self):
-        args = self.eval_args()
-        json_args = json.dumps(args)
-        # XXX: we use self.args.profile and not self.profile
-        #      because we want the raw profile_key (so plugin handle C.PROF_KEY_NONE)
-        try:
-            await self.host.bridge.debug_signal_fake(
-                self.args.signal, json_args, self.args.profile
-            )
-        except Exception as e:
-            self.disp(_("Can't send fake signal: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_ERROR)
-        else:
-            self.host.quit()
-
-
-class bridge(base.CommandBase):
-    subcommands = (Method, Signal)
-
-    def __init__(self, host):
-        super(bridge, self).__init__(
-            host, "bridge", use_profile=False, help=_("bridge s(t)imulation")
-        )
-
-
-class Monitor(base.CommandBase):
-    def __init__(self, host):
-        super(Monitor, self).__init__(
-            host,
-            "monitor",
-            use_verbose=True,
-            use_profile=False,
-            use_output=C.OUTPUT_XML,
-            help=_("monitor XML stream"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-d",
-            "--direction",
-            choices=("in", "out", "both"),
-            default="both",
-            help=_("stream direction filter"),
-        )
-
-    async def print_xml(self, direction, xml_data, profile):
-        if self.args.direction == "in" and direction != "IN":
-            return
-        if self.args.direction == "out" and direction != "OUT":
-            return
-        verbosity = self.host.verbosity
-        if not xml_data.strip():
-            if verbosity <= 2:
-                return
-            whiteping = True
-        else:
-            whiteping = False
-
-        if verbosity:
-            profile_disp = f" ({profile})" if verbosity > 1 else ""
-            if direction == "IN":
-                self.disp(
-                    A.color(
-                        A.BOLD, A.FG_YELLOW, "<<<===== IN ====", A.FG_WHITE, profile_disp
-                    )
-                )
-            else:
-                self.disp(
-                    A.color(
-                        A.BOLD, A.FG_CYAN, "==== OUT ====>>>", A.FG_WHITE, profile_disp
-                    )
-                )
-        if whiteping:
-            self.disp("[WHITESPACE PING]")
-        else:
-            try:
-                await self.output(xml_data)
-            except Exception:
-                #  initial stream is not valid XML,
-                # in this case we print directly to data
-                #  FIXME: we should test directly lxml.etree.XMLSyntaxError
-                #        but importing lxml directly here is not clean
-                #        should be wrapped in a custom Exception
-                self.disp(xml_data)
-                self.disp("")
-
-    async def start(self):
-        self.host.bridge.register_signal("xml_log", self.print_xml, "plugin")
-
-
-class Theme(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "theme", help=_("print colours used with your background")
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        print(f"background currently used: {A.BOLD}{self.host.background}{A.RESET}\n")
-        for attr in dir(C):
-            if not attr.startswith("A_"):
-                continue
-            color = getattr(C, attr)
-            if attr == "A_LEVEL_COLORS":
-                # This constant contains multiple colors
-                self.disp("LEVEL COLORS: ", end=" ")
-                for idx, c in enumerate(color):
-                    last = idx == len(color) - 1
-                    end = "\n" if last else " "
-                    self.disp(
-                        c + f"LEVEL_{idx}" + A.RESET + (", " if not last else ""), end=end
-                    )
-            else:
-                text = attr[2:]
-                self.disp(A.color(color, text))
-        self.host.quit()
-
-
-class Debug(base.CommandBase):
-    subcommands = (bridge, Monitor, Theme)
-
-    def __init__(self, host):
-        super(Debug, self).__init__(
-            host, "debug", use_profile=False, help=_("debugging tools")
-        )
--- a/sat_frontends/jp/cmd_encryption.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,231 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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_frontends.jp import base
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from sat_frontends.jp import xmlui_manager
-
-__commands__ = ["Encryption"]
-
-
-class EncryptionAlgorithms(base.CommandBase):
-
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        super(EncryptionAlgorithms, self).__init__(
-            host, "algorithms",
-            use_output=C.OUTPUT_LIST_DICT,
-            extra_outputs=extra_outputs,
-            use_profile=False,
-            help=_("show available encryption algorithms"))
-
-    def add_parser_options(self):
-        pass
-
-    def default_output(self, plugins):
-        if not plugins:
-            self.disp(_("No encryption plugin registered!"))
-        else:
-            self.disp(_("Following encryption algorithms are available: {algos}").format(
-                algos=', '.join([p['name'] for p in plugins])))
-
-    async def start(self):
-        try:
-            plugins_ser = await self.host.bridge.encryption_plugins_get()
-            plugins = data_format.deserialise(plugins_ser, type_check=list)
-        except Exception as e:
-            self.disp(f"can't retrieve plugins: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(plugins)
-            self.host.quit()
-
-
-class EncryptionGet(base.CommandBase):
-
-    def __init__(self, host):
-        super(EncryptionGet, self).__init__(
-            host, "get",
-            use_output=C.OUTPUT_DICT,
-            help=_("get encryption session data"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the entity to check")
-        )
-
-    async def start(self):
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-        try:
-            serialised = await self.host.bridge.message_encryption_get(jid, self.profile)
-        except Exception as e:
-            self.disp(f"can't get session: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        session_data = data_format.deserialise(serialised)
-        if session_data is None:
-            self.disp(
-                "No encryption session found, the messages are sent in plain text.")
-            self.host.quit(C.EXIT_NOT_FOUND)
-        await self.output(session_data)
-        self.host.quit()
-
-
-class EncryptionStart(base.CommandBase):
-
-    def __init__(self, host):
-        super(EncryptionStart, self).__init__(
-            host, "start",
-            help=_("start encrypted session with an entity"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--encrypt-noreplace",
-            action="store_true",
-            help=_("don't replace encryption algorithm if an other one is already used"))
-        algorithm = self.parser.add_mutually_exclusive_group()
-        algorithm.add_argument(
-            "-n", "--name", help=_("algorithm name (DEFAULT: choose automatically)"))
-        algorithm.add_argument(
-            "-N", "--namespace",
-            help=_("algorithm namespace (DEFAULT: choose automatically)"))
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the entity to stop encrypted session with")
-        )
-
-    async def start(self):
-        if self.args.name is not None:
-            try:
-                namespace = await self.host.bridge.encryption_namespace_get(self.args.name)
-            except Exception as e:
-                self.disp(f"can't get encryption namespace: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        elif self.args.namespace is not None:
-            namespace = self.args.namespace
-        else:
-            namespace = ""
-
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-
-        try:
-            await self.host.bridge.message_encryption_start(
-                jid, namespace, not self.args.encrypt_noreplace,
-                self.profile)
-        except Exception as e:
-            self.disp(f"can't get encryption namespace: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-
-class EncryptionStop(base.CommandBase):
-
-    def __init__(self, host):
-        super(EncryptionStop, self).__init__(
-            host, "stop",
-            help=_("stop encrypted session with an entity"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the entity to stop encrypted session with")
-        )
-
-    async def start(self):
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-        try:
-            await self.host.bridge.message_encryption_stop(jid, self.profile)
-        except Exception as e:
-            self.disp(f"can't end encrypted session: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-
-class TrustUI(base.CommandBase):
-
-    def __init__(self, host):
-        super(TrustUI, self).__init__(
-            host, "ui",
-            help=_("get UI to manage trust"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the entity to stop encrypted session with")
-        )
-        algorithm = self.parser.add_mutually_exclusive_group()
-        algorithm.add_argument(
-            "-n", "--name", help=_("algorithm name (DEFAULT: current algorithm)"))
-        algorithm.add_argument(
-            "-N", "--namespace",
-            help=_("algorithm namespace (DEFAULT: current algorithm)"))
-
-    async def start(self):
-        if self.args.name is not None:
-            try:
-                namespace = await self.host.bridge.encryption_namespace_get(self.args.name)
-            except Exception as e:
-                self.disp(f"can't get encryption namespace: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        elif self.args.namespace is not None:
-            namespace = self.args.namespace
-        else:
-            namespace = ""
-
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-
-        try:
-            xmlui_raw = await self.host.bridge.encryption_trust_ui_get(
-                jid, namespace, self.profile)
-        except Exception as e:
-            self.disp(f"can't get encryption session trust UI: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        xmlui = xmlui_manager.create(self.host, xmlui_raw)
-        await xmlui.show()
-        if xmlui.type != C.XMLUI_DIALOG:
-            await xmlui.submit_form()
-        self.host.quit()
-
-class EncryptionTrust(base.CommandBase):
-    subcommands = (TrustUI,)
-
-    def __init__(self, host):
-        super(EncryptionTrust, self).__init__(
-            host, "trust", use_profile=False, help=_("trust manangement")
-        )
-
-
-class Encryption(base.CommandBase):
-    subcommands = (EncryptionAlgorithms, EncryptionGet, EncryptionStart, EncryptionStop,
-                   EncryptionTrust)
-
-    def __init__(self, host):
-        super(Encryption, self).__init__(
-            host, "encryption", use_profile=False, help=_("encryption sessions handling")
-        )
--- a/sat_frontends/jp/cmd_event.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,755 +0,0 @@
-#!/usr/bin/env python3
-
-
-# libervia-cli: Libervia CLI frontend
-# Copyright (C) 2009-2021 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/>.
-
-
-import argparse
-import sys
-
-from sqlalchemy import desc
-
-from libervia.backend.core.i18n import _
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common import date_utils
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common.ansi import ANSI as A
-from sat_frontends.jp import common
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.jp.constants import Const as C
-
-from . import base
-
-__commands__ = ["Event"]
-
-OUTPUT_OPT_TABLE = "table"
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_LIST_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS, C.CACHE},
-            use_verbose=True,
-            extra_outputs={
-                "default": self.default_output,
-            },
-            help=_("get event(s) data"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            events_data_s = await self.host.bridge.events_get(
-                self.args.service,
-                self.args.node,
-                self.args.items,
-                self.get_pubsub_extra(),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get events data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            events_data = data_format.deserialise(events_data_s, type_check=list)
-            await self.output(events_data)
-            self.host.quit()
-
-    def default_output(self, events):
-        nb_events = len(events)
-        for idx, event in enumerate(events):
-            names = event["name"]
-            name = names.get("") or next(iter(names.values()))
-            start = event["start"]
-            start_human = date_utils.date_fmt(
-                start, "medium", tz_info=date_utils.TZ_LOCAL
-            )
-            end = event["end"]
-            self.disp(A.color(
-                A.BOLD, start_human, A.RESET, " ",
-                f"({date_utils.delta2human(start, end)}) ",
-                C.A_HEADER, name
-            ))
-            if self.verbosity > 0:
-                descriptions = event.get("descriptions", [])
-                if descriptions:
-                    self.disp(descriptions[0]["description"])
-            if idx < (nb_events-1):
-                self.disp("")
-
-
-class CategoryAction(argparse.Action):
-
-    def __init__(self, option_strings, dest, nargs=None, metavar=None, **kwargs):
-        if nargs is not None or metavar is not None:
-            raise ValueError("nargs and metavar must not be used")
-        if metavar is not None:
-            metavar="TERM WIKIDATA_ID LANG"
-        if "--help" in sys.argv:
-            # FIXME: dirty workaround to have correct --help message
-            #   argparse doesn't normally allow variable number of arguments beside "+"
-            #   and "*", this workaround show METAVAR as 3 arguments were expected, while
-            #   we can actuall use 1, 2 or 3.
-            nargs = 3
-            metavar = ("TERM", "[WIKIDATA_ID]", "[LANG]")
-        else:
-            nargs = "+"
-
-        super().__init__(option_strings, dest, metavar=metavar, nargs=nargs, **kwargs)
-
-    def __call__(self, parser, namespace, values, option_string=None):
-        categories = getattr(namespace, self.dest)
-        if categories is None:
-            categories = []
-            setattr(namespace, self.dest, categories)
-
-        if not values:
-            parser.error("category values must be set")
-
-        category = {
-            "term": values[0]
-        }
-
-        if len(values) == 1:
-            pass
-        elif len(values) == 2:
-            value = values[1]
-            if value.startswith("Q"):
-                category["wikidata_id"] = value
-            else:
-                category["language"] = value
-        elif len(values) == 3:
-            __, wd, lang = values
-            category["wikidata_id"] = wd
-            category["language"] = lang
-        else:
-            parser.error("Category can't have more than 3 arguments")
-
-        categories.append(category)
-
-
-class EventBase:
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-S", "--start", type=base.date_decoder, metavar="TIME_PATTERN",
-            help=_("the start time of the event"))
-        end_group = self.parser.add_mutually_exclusive_group()
-        end_group.add_argument(
-            "-E", "--end", type=base.date_decoder, metavar="TIME_PATTERN",
-            help=_("the time of the end of the event"))
-        end_group.add_argument(
-            "-D", "--duration", help=_("duration of the event"))
-        self.parser.add_argument(
-            "-H", "--head-picture", help="URL to a picture to use as head-picture"
-        )
-        self.parser.add_argument(
-            "-d", "--description", help="plain text description the event"
-        )
-        self.parser.add_argument(
-            "-C", "--category", action=CategoryAction, dest="categories",
-            help="Category of the event"
-        )
-        self.parser.add_argument(
-            "-l", "--location", action="append", nargs="+", metavar="[KEY] VALUE",
-            help="Location metadata"
-        )
-        rsvp_group = self.parser.add_mutually_exclusive_group()
-        rsvp_group.add_argument(
-            "--rsvp", action="store_true", help=_("RSVP is requested"))
-        rsvp_group.add_argument(
-            "--rsvp_json", metavar="JSON", help=_("JSON description of RSVP form"))
-        for node_type in ("invitees", "comments", "blog", "schedule"):
-            self.parser.add_argument(
-                f"--{node_type}",
-                nargs=2,
-                metavar=("JID", "NODE"),
-                help=_("link {node_type} pubsub node").format(node_type=node_type)
-            )
-        self.parser.add_argument(
-            "-a", "--attachment", action="append", dest="attachments",
-            help=_("attach a file")
-        )
-        self.parser.add_argument("--website", help=_("website of the event"))
-        self.parser.add_argument(
-            "--status", choices=["confirmed", "tentative", "cancelled"],
-            help=_("status of the event")
-        )
-        self.parser.add_argument(
-            "-T", "--language", metavar="LANG", action="append", dest="languages",
-            help=_("main languages spoken at the event")
-        )
-        self.parser.add_argument(
-            "--wheelchair", choices=["full", "partial", "no"],
-            help=_("is the location accessible by wheelchair")
-        )
-        self.parser.add_argument(
-            "--external",
-            nargs=3,
-            metavar=("JID", "NODE", "ITEM"),
-            help=_("link to an external event")
-        )
-
-    def get_event_data(self):
-        if self.args.duration is not None:
-            if self.args.start is None:
-                self.parser.error("--start must be send if --duration is used")
-            # if duration is used, we simply add it to start time to get end time
-            self.args.end = base.date_decoder(f"{self.args.start} + {self.args.duration}")
-
-        event = {}
-        if self.args.name is not None:
-            event["name"] = {"": self.args.name}
-
-        if self.args.start is not None:
-            event["start"] = self.args.start
-
-        if self.args.end is not None:
-            event["end"] = self.args.end
-
-        if self.args.head_picture:
-            event["head-picture"] = {
-                "sources": [{
-                    "url": self.args.head_picture
-                }]
-            }
-        if self.args.description:
-            event["descriptions"] = [
-                {
-                    "type": "text",
-                    "description": self.args.description
-                }
-            ]
-        if self.args.categories:
-            event["categories"] = self.args.categories
-        if self.args.location is not None:
-            location = {}
-            for location_data in self.args.location:
-                if len(location_data) == 1:
-                    location["description"] = location_data[0]
-                else:
-                    key, *values = location_data
-                    location[key] = " ".join(values)
-            event["locations"] = [location]
-
-        if self.args.rsvp:
-            event["rsvp"] = [{}]
-        elif self.args.rsvp_json:
-            if isinstance(self.args.rsvp_elt, dict):
-                event["rsvp"] = [self.args.rsvp_json]
-            else:
-                event["rsvp"] = self.args.rsvp_json
-
-        for node_type in ("invitees", "comments", "blog", "schedule"):
-            value = getattr(self.args, node_type)
-            if value:
-                service, node = value
-                event[node_type] = {"service": service, "node": node}
-
-        if self.args.attachments:
-            attachments = event["attachments"] = []
-            for attachment in self.args.attachments:
-                attachments.append({
-                    "sources": [{"url": attachment}]
-                })
-
-        extra = {}
-
-        for arg in ("website", "status", "languages"):
-            value = getattr(self.args, arg)
-            if value is not None:
-                extra[arg] = value
-        if self.args.wheelchair is not None:
-            extra["accessibility"] = {"wheelchair": self.args.wheelchair}
-
-        if extra:
-            event["extra"] = extra
-
-        if self.args.external:
-            ext_jid, ext_node, ext_item = self.args.external
-            event["external"] = {
-                "jid": ext_jid,
-                "node": ext_node,
-                "item": ext_item
-            }
-        return event
-
-
-class Create(EventBase, base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "create",
-            use_pubsub=True,
-            help=_("create or replace event"),
-        )
-
-    def add_parser_options(self):
-        super().add_parser_options()
-        self.parser.add_argument(
-            "-i",
-            "--id",
-            default="",
-            help=_("ID of the PubSub Item"),
-        )
-        # name is mandatory here
-        self.parser.add_argument("name", help=_("name of the event"))
-
-    async def start(self):
-        if self.args.start is None:
-            self.parser.error("--start must be set")
-        event_data = self.get_event_data()
-        # we check self.args.end after get_event_data because it may be set there id
-        # --duration is used
-        if self.args.end is None:
-            self.parser.error("--end or --duration must be set")
-        try:
-            await self.host.bridge.event_create(
-                data_format.serialise(event_data),
-                self.args.id,
-                self.args.node,
-                self.args.service,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create event: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("Event created successfuly)"))
-            self.host.quit()
-
-
-class Modify(EventBase, base.CommandBase):
-    def __init__(self, host):
-        super(Modify, self).__init__(
-            host,
-            "modify",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("modify an existing event"),
-        )
-        EventBase.__init__(self)
-
-    def add_parser_options(self):
-        super().add_parser_options()
-        # name is optional here
-        self.parser.add_argument("-N", "--name", help=_("name of the event"))
-
-    async def start(self):
-        event_data = self.get_event_data()
-        try:
-            await self.host.bridge.event_modify(
-                data_format.serialise(event_data),
-                self.args.item,
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't update event data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class InviteeGet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            use_verbose=True,
-            help=_("get event attendance"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j", "--jid", action="append", dest="jids", default=[],
-            help=_("only retrieve RSVP from those JIDs")
-        )
-
-    async def start(self):
-        try:
-            event_data_s = await self.host.bridge.event_invitee_get(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.jids,
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get event data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            event_data = data_format.deserialise(event_data_s)
-            await self.output(event_data)
-            self.host.quit()
-
-
-class InviteeSet(base.CommandBase):
-    def __init__(self, host):
-        super(InviteeSet, self).__init__(
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM},
-            help=_("set event attendance"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            metavar=("KEY", "VALUE"),
-            help=_("configuration field to set"),
-        )
-
-    async def start(self):
-        # TODO: handle RSVP with XMLUI in a similar way as for `ad-hoc run`
-        fields = dict(self.args.fields) if self.args.fields else {}
-        try:
-            self.host.bridge.event_invitee_set(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                data_format.serialise(fields),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set event data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class InviteesList(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        base.CommandBase.__init__(
-            self,
-            host,
-            "list",
-            use_output=C.OUTPUT_DICT_DICT,
-            extra_outputs=extra_outputs,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("get event attendance"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-m",
-            "--missing",
-            action="store_true",
-            help=_("show missing people (invited but no R.S.V.P. so far)"),
-        )
-        self.parser.add_argument(
-            "-R",
-            "--no-rsvp",
-            action="store_true",
-            help=_("don't show people which gave R.S.V.P."),
-        )
-
-    def _attend_filter(self, attend, row):
-        if attend == "yes":
-            attend_color = C.A_SUCCESS
-        elif attend == "no":
-            attend_color = C.A_FAILURE
-        else:
-            attend_color = A.FG_WHITE
-        return A.color(attend_color, attend)
-
-    def _guests_filter(self, guests):
-        return "(" + str(guests) + ")" if guests else ""
-
-    def default_output(self, event_data):
-        data = []
-        attendees_yes = 0
-        attendees_maybe = 0
-        attendees_no = 0
-        attendees_missing = 0
-        guests = 0
-        guests_maybe = 0
-        for jid_, jid_data in event_data.items():
-            jid_data["jid"] = jid_
-            try:
-                guests_int = int(jid_data["guests"])
-            except (ValueError, KeyError):
-                pass
-            attend = jid_data.get("attend", "")
-            if attend == "yes":
-                attendees_yes += 1
-                guests += guests_int
-            elif attend == "maybe":
-                attendees_maybe += 1
-                guests_maybe += guests_int
-            elif attend == "no":
-                attendees_no += 1
-                jid_data["guests"] = ""
-            else:
-                attendees_missing += 1
-                jid_data["guests"] = ""
-            data.append(jid_data)
-
-        show_table = OUTPUT_OPT_TABLE in self.args.output_opts
-
-        table = common.Table.from_list_dict(
-            self.host,
-            data,
-            ("nick",) + (("jid",) if self.host.verbosity else ()) + ("attend", "guests"),
-            headers=None,
-            filters={
-                "nick": A.color(C.A_HEADER, "{}" if show_table else "{} "),
-                "jid": "{}" if show_table else "{} ",
-                "attend": self._attend_filter,
-                "guests": "{}" if show_table else self._guests_filter,
-            },
-            defaults={"nick": "", "attend": "", "guests": 1},
-        )
-        if show_table:
-            table.display()
-        else:
-            table.display_blank(show_header=False, col_sep="")
-
-        if not self.args.no_rsvp:
-            self.disp("")
-            self.disp(
-                A.color(
-                    C.A_SUBHEADER,
-                    _("Attendees: "),
-                    A.RESET,
-                    str(len(data)),
-                    _(" ("),
-                    C.A_SUCCESS,
-                    _("yes: "),
-                    str(attendees_yes),
-                    A.FG_WHITE,
-                    _(", maybe: "),
-                    str(attendees_maybe),
-                    ", ",
-                    C.A_FAILURE,
-                    _("no: "),
-                    str(attendees_no),
-                    A.RESET,
-                    ")",
-                )
-            )
-            self.disp(
-                A.color(C.A_SUBHEADER, _("confirmed guests: "), A.RESET, str(guests))
-            )
-            self.disp(
-                A.color(
-                    C.A_SUBHEADER,
-                    _("unconfirmed guests: "),
-                    A.RESET,
-                    str(guests_maybe),
-                )
-            )
-            self.disp(
-                A.color(C.A_SUBHEADER, _("total: "), A.RESET, str(guests + guests_maybe))
-            )
-        if attendees_missing:
-            self.disp("")
-            self.disp(
-                A.color(
-                    C.A_SUBHEADER,
-                    _("missing people (no reply): "),
-                    A.RESET,
-                    str(attendees_missing),
-                )
-            )
-
-    async def start(self):
-        if self.args.no_rsvp and not self.args.missing:
-            self.parser.error(_("you need to use --missing if you use --no-rsvp"))
-        if not self.args.missing:
-            prefilled = {}
-        else:
-            # we get prefilled data with all people
-            try:
-                affiliations = await self.host.bridge.ps_node_affiliations_get(
-                    self.args.service,
-                    self.args.node,
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(f"can't get node affiliations: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                # we fill all affiliations with empty data, answered one will be filled
-                # below. We only consider people with "publisher" affiliation as invited,
-                # creators are not, and members can just observe
-                prefilled = {
-                    jid_: {}
-                    for jid_, affiliation in affiliations.items()
-                    if affiliation in ("publisher",)
-                }
-
-        try:
-            event_data = await self.host.bridge.event_invitees_list(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get event data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            # we fill nicknames and keep only requested people
-
-        if self.args.no_rsvp:
-            for jid_ in event_data:
-                # if there is a jid in event_data it must be there in prefilled too
-                # otherwie somebody is not on the invitees list
-                try:
-                    del prefilled[jid_]
-                except KeyError:
-                    self.disp(
-                        A.color(
-                            C.A_WARNING,
-                            f"We got a RSVP from somebody who was not in invitees "
-                            f"list: {jid_}",
-                        ),
-                        error=True,
-                    )
-        else:
-            # we replace empty dicts for existing people with R.S.V.P. data
-            prefilled.update(event_data)
-
-            # we get nicknames for everybody, make it easier for organisers
-        for jid_, data in prefilled.items():
-            id_data = await self.host.bridge.identity_get(jid_, [], True, self.profile)
-            id_data = data_format.deserialise(id_data)
-            data["nick"] = id_data["nicknames"][0]
-
-        await self.output(prefilled)
-        self.host.quit()
-
-
-class InviteeInvite(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "invite",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("invite someone to the event through email"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-e",
-            "--email",
-            action="append",
-            default=[],
-            help="email(s) to send the invitation to",
-        )
-        self.parser.add_argument(
-            "-N",
-            "--name",
-            default="",
-            help="name of the invitee",
-        )
-        self.parser.add_argument(
-            "-H",
-            "--host-name",
-            default="",
-            help="name of the host",
-        )
-        self.parser.add_argument(
-            "-l",
-            "--lang",
-            default="",
-            help="main language spoken by the invitee",
-        )
-        self.parser.add_argument(
-            "-U",
-            "--url-template",
-            default="",
-            help="template to construct the URL",
-        )
-        self.parser.add_argument(
-            "-S",
-            "--subject",
-            default="",
-            help="subject of the invitation email (default: generic subject)",
-        )
-        self.parser.add_argument(
-            "-b",
-            "--body",
-            default="",
-            help="body of the invitation email (default: generic body)",
-        )
-
-    async def start(self):
-        email = self.args.email[0] if self.args.email else None
-        emails_extra = self.args.email[1:]
-
-        try:
-            await self.host.bridge.event_invite_by_email(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                email,
-                emails_extra,
-                self.args.name,
-                self.args.host_name,
-                self.args.lang,
-                self.args.url_template,
-                self.args.subject,
-                self.args.body,
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create invitation: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Invitee(base.CommandBase):
-    subcommands = (InviteeGet, InviteeSet, InviteesList, InviteeInvite)
-
-    def __init__(self, host):
-        super(Invitee, self).__init__(
-            host, "invitee", use_profile=False, help=_("manage invities")
-        )
-
-
-class Event(base.CommandBase):
-    subcommands = (Get, Create, Modify, Invitee)
-
-    def __init__(self, host):
-        super(Event, self).__init__(
-            host, "event", use_profile=False, help=_("event management")
-        )
--- a/sat_frontends/jp/cmd_file.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1108 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from . import xmlui_manager
-import sys
-import os
-import os.path
-import tarfile
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.jp import common
-from sat_frontends.tools import jid
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import utils
-from urllib.parse import urlparse
-from pathlib import Path
-import tempfile
-import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI
-import json
-
-__commands__ = ["File"]
-DEFAULT_DEST = "downloaded_file"
-
-
-class Send(base.CommandBase):
-    def __init__(self, host):
-        super(Send, self).__init__(
-            host,
-            "send",
-            use_progress=True,
-            use_verbose=True,
-            help=_("send a file to a contact"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "files", type=str, nargs="+", metavar="file", help=_("a list of file")
-        )
-        self.parser.add_argument("jid", help=_("the destination jid"))
-        self.parser.add_argument(
-            "-b", "--bz2", action="store_true", help=_("make a bzip2 tarball")
-        )
-        self.parser.add_argument(
-            "-d",
-            "--path",
-            help=("path to the directory where the file must be stored"),
-        )
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            help=("namespace of the file"),
-        )
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help=("name to use (DEFAULT: use source file name)"),
-        )
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("end-to-end encrypt the file transfer")
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File copy started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File sent successfully"), 2)
-
-    async def on_progress_error(self, error_msg):
-        if error_msg == C.PROGRESS_ERROR_DECLINED:
-            self.disp(_("The file has been refused by your contact"))
-        else:
-            self.disp(_("Error while sending file: {}").format(error_msg), error=True)
-
-    async def got_id(self, data, file_):
-        """Called when a progress id has been received
-
-        @param pid(unicode): progress id
-        @param file_(str): file path
-        """
-        # FIXME: this show progress only for last progress_id
-        self.disp(_("File request sent to {jid}".format(jid=self.args.jid)), 1)
-        try:
-            await self.set_progress_id(data["progress"])
-        except KeyError:
-            # TODO: if 'xmlui' key is present, manage xmlui message display
-            self.disp(_("Can't send file to {jid}".format(jid=self.args.jid)), error=True)
-            self.host.quit(2)
-
-    async def start(self):
-        for file_ in self.args.files:
-            if not os.path.exists(file_):
-                self.disp(
-                    _("file {file_} doesn't exist!").format(file_=repr(file_)), error=True
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-            if not self.args.bz2 and os.path.isdir(file_):
-                self.disp(
-                    _(
-                        "{file_} is a dir! Please send files inside or use compression"
-                    ).format(file_=repr(file_))
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-
-        extra = {}
-        if self.args.path:
-            extra["path"] = self.args.path
-        if self.args.namespace:
-            extra["namespace"] = self.args.namespace
-        if self.args.encrypt:
-            extra["encrypted"] = True
-
-        if self.args.bz2:
-            with tempfile.NamedTemporaryFile("wb", delete=False) as buf:
-                self.host.add_on_quit_callback(os.unlink, buf.name)
-                self.disp(_("bz2 is an experimental option, use with caution"))
-                # FIXME: check free space
-                self.disp(_("Starting compression, please wait..."))
-                sys.stdout.flush()
-                bz2 = tarfile.open(mode="w:bz2", fileobj=buf)
-                archive_name = "{}.tar.bz2".format(
-                    os.path.basename(self.args.files[0]) or "compressed_files"
-                )
-                for file_ in self.args.files:
-                    self.disp(_("Adding {}").format(file_), 1)
-                    bz2.add(file_)
-                bz2.close()
-                self.disp(_("Done !"), 1)
-
-                try:
-                    send_data = await self.host.bridge.file_send(
-                        self.args.jid,
-                        buf.name,
-                        self.args.name or archive_name,
-                        "",
-                        data_format.serialise(extra),
-                        self.profile,
-                    )
-                except Exception as e:
-                    self.disp(f"can't send file: {e}", error=True)
-                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-                else:
-                    await self.got_id(send_data, file_)
-        else:
-            for file_ in self.args.files:
-                path = os.path.abspath(file_)
-                try:
-                    send_data = await self.host.bridge.file_send(
-                        self.args.jid,
-                        path,
-                        self.args.name,
-                        "",
-                        data_format.serialise(extra),
-                        self.profile,
-                    )
-                except Exception as e:
-                    self.disp(f"can't send file {file_!r}: {e}", error=True)
-                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-                else:
-                    await self.got_id(send_data, file_)
-
-
-class Request(base.CommandBase):
-    def __init__(self, host):
-        super(Request, self).__init__(
-            host,
-            "request",
-            use_progress=True,
-            use_verbose=True,
-            help=_("request a file from a contact"),
-        )
-
-    @property
-    def filename(self):
-        return self.args.name or self.args.hash or "output"
-
-    def add_parser_options(self):
-        self.parser.add_argument("jid", help=_("the destination jid"))
-        self.parser.add_argument(
-            "-D",
-            "--dest",
-            help=_(
-                "destination path where the file will be saved (default: "
-                "[current_dir]/[name|hash])"
-            ),
-        )
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help=_("name of the file"),
-        )
-        self.parser.add_argument(
-            "-H",
-            "--hash",
-            default="",
-            help=_("hash of the file"),
-        )
-        self.parser.add_argument(
-            "-a",
-            "--hash-algo",
-            default="sha-256",
-            help=_("hash algorithm use for --hash (default: sha-256)"),
-        )
-        self.parser.add_argument(
-            "-d",
-            "--path",
-            help=("path to the directory containing the file"),
-        )
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            help=("namespace of the file"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("overwrite existing file without confirmation"),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File copy started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File received successfully"), 2)
-
-    async def on_progress_error(self, error_msg):
-        if error_msg == C.PROGRESS_ERROR_DECLINED:
-            self.disp(_("The file request has been refused"))
-        else:
-            self.disp(_("Error while requesting file: {}").format(error_msg), error=True)
-
-    async def start(self):
-        if not self.args.name and not self.args.hash:
-            self.parser.error(_("at least one of --name or --hash must be provided"))
-        if self.args.dest:
-            path = os.path.abspath(os.path.expanduser(self.args.dest))
-            if os.path.isdir(path):
-                path = os.path.join(path, self.filename)
-        else:
-            path = os.path.abspath(self.filename)
-
-        if os.path.exists(path) and not self.args.force:
-            message = _("File {path} already exists! Do you want to overwrite?").format(
-                path=path
-            )
-            await self.host.confirm_or_quit(message, _("file request cancelled"))
-
-        self.full_dest_jid = await self.host.get_full_jid(self.args.jid)
-        extra = {}
-        if self.args.path:
-            extra["path"] = self.args.path
-        if self.args.namespace:
-            extra["namespace"] = self.args.namespace
-        try:
-            progress_id = await self.host.bridge.file_jingle_request(
-                self.full_dest_jid,
-                path,
-                self.args.name,
-                self.args.hash,
-                self.args.hash_algo if self.args.hash else "",
-                extra,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't request file: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.set_progress_id(progress_id)
-
-
-class Receive(base.CommandAnswering):
-    def __init__(self, host):
-        super(Receive, self).__init__(
-            host,
-            "receive",
-            use_progress=True,
-            use_verbose=True,
-            help=_("wait for a file to be sent by a contact"),
-        )
-        self._overwrite_refused = False  # True when one overwrite as already been refused
-        self.action_callbacks = {
-            C.META_TYPE_FILE: self.on_file_action,
-            C.META_TYPE_OVERWRITE: self.on_overwrite_action,
-            C.META_TYPE_NOT_IN_ROSTER_LEAK: self.on_not_in_roster_action,
-        }
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jids",
-            nargs="*",
-            help=_("jids accepted (accept everything if none is specified)"),
-        )
-        self.parser.add_argument(
-            "-m",
-            "--multiple",
-            action="store_true",
-            help=_("accept multiple files (you'll have to stop manually)"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_(
-                "force overwritting of existing files (/!\\ name is choosed by sender)"
-            ),
-        )
-        self.parser.add_argument(
-            "--path",
-            default=".",
-            metavar="DIR",
-            help=_("destination path (default: working directory)"),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File copy started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File received successfully"), 2)
-        if metadata.get("hash_verified", False):
-            try:
-                self.disp(
-                    _("hash checked: {metadata['hash_algo']}:{metadata['hash']}"), 1
-                )
-            except KeyError:
-                self.disp(_("hash is checked but hash value is missing", 1), error=True)
-        else:
-            self.disp(_("hash can't be verified"), 1)
-
-    async def on_progress_error(self, e):
-        self.disp(_("Error while receiving file: {e}").format(e=e), error=True)
-
-    def get_xmlui_id(self, action_data):
-        # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
-        #        should be available in the futur
-        # TODO: XMLUI module
-        try:
-            xml_ui = action_data["xmlui"]
-        except KeyError:
-            self.disp(_("Action has no XMLUI"), 1)
-        else:
-            ui = ET.fromstring(xml_ui.encode("utf-8"))
-            xmlui_id = ui.get("submit")
-            if not xmlui_id:
-                self.disp(_("Invalid XMLUI received"), error=True)
-            return xmlui_id
-
-    async def on_file_action(self, action_data, action_id, security_limit, profile):
-        xmlui_id = self.get_xmlui_id(action_data)
-        if xmlui_id is None:
-            return self.host.quit_from_signal(1)
-        try:
-            from_jid = jid.JID(action_data["from_jid"])
-        except KeyError:
-            self.disp(_("Ignoring action without from_jid data"), 1)
-            return
-        try:
-            progress_id = action_data["progress_id"]
-        except KeyError:
-            self.disp(_("ignoring action without progress id"), 1)
-            return
-
-        if not self.bare_jids or from_jid.bare in self.bare_jids:
-            if self._overwrite_refused:
-                self.disp(_("File refused because overwrite is needed"), error=True)
-                await self.host.bridge.action_launch(
-                    xmlui_id, data_format.serialise({"cancelled": C.BOOL_TRUE}),
-                    profile_key=profile
-                )
-                return self.host.quit_from_signal(2)
-            await self.set_progress_id(progress_id)
-            xmlui_data = {"path": self.path}
-            await self.host.bridge.action_launch(
-                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
-            )
-
-    async def on_overwrite_action(self, action_data, action_id, security_limit, profile):
-        xmlui_id = self.get_xmlui_id(action_data)
-        if xmlui_id is None:
-            return self.host.quit_from_signal(1)
-        try:
-            progress_id = action_data["progress_id"]
-        except KeyError:
-            self.disp(_("ignoring action without progress id"), 1)
-            return
-        self.disp(_("Overwriting needed"), 1)
-
-        if progress_id == self.progress_id:
-            if self.args.force:
-                self.disp(_("Overwrite accepted"), 2)
-            else:
-                self.disp(_("Refused to overwrite"), 2)
-                self._overwrite_refused = True
-
-            xmlui_data = {"answer": C.bool_const(self.args.force)}
-            await self.host.bridge.action_launch(
-                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
-            )
-
-    async def on_not_in_roster_action(self, action_data, action_id, security_limit, profile):
-        xmlui_id = self.get_xmlui_id(action_data)
-        if xmlui_id is None:
-            return self.host.quit_from_signal(1)
-        try:
-            from_jid = jid.JID(action_data["from_jid"])
-        except ValueError:
-            self.disp(
-                _('invalid "from_jid" value received, ignoring: {value}').format(
-                    value=from_jid
-                ),
-                error=True,
-            )
-            return
-        except KeyError:
-            self.disp(_('ignoring action without "from_jid" value'), error=True)
-            return
-        self.disp(_("Confirmation needed for request from an entity not in roster"), 1)
-
-        if from_jid.bare in self.bare_jids:
-            # if the sender is expected, we can confirm the session
-            confirmed = True
-            self.disp(_("Sender confirmed because she or he is explicitly expected"), 1)
-        else:
-            xmlui = xmlui_manager.create(self.host, action_data["xmlui"])
-            confirmed = await self.host.confirm(xmlui.dlg.message)
-
-        xmlui_data = {"answer": C.bool_const(confirmed)}
-        await self.host.bridge.action_launch(
-            xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
-        )
-        if not confirmed and not self.args.multiple:
-            self.disp(_("Session refused for {from_jid}").format(from_jid=from_jid))
-            self.host.quit_from_signal(0)
-
-    async def start(self):
-        self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids]
-        self.path = os.path.abspath(self.args.path)
-        if not os.path.isdir(self.path):
-            self.disp(_("Given path is not a directory !", error=True))
-            self.host.quit(C.EXIT_BAD_ARG)
-        if self.args.multiple:
-            self.host.quit_on_progress_end = False
-        self.disp(_("waiting for incoming file request"), 2)
-        await self.start_answering()
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        super(Get, self).__init__(
-            host,
-            "get",
-            use_progress=True,
-            use_verbose=True,
-            help=_("download a file from URI"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-o",
-            "--dest-file",
-            type=str,
-            default="",
-            help=_("destination file (DEFAULT: filename from URL)"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("overwrite existing file without confirmation"),
-        )
-        self.parser.add_argument(
-            "attachment", type=str,
-            help=_("URI of the file to retrieve or JSON of the whole attachment")
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File download started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File downloaded successfully"), 2)
-
-    async def on_progress_error(self, error_msg):
-        self.disp(_("Error while downloading file: {}").format(error_msg), error=True)
-
-    async def got_id(self, data):
-        """Called when a progress id has been received"""
-        try:
-            await self.set_progress_id(data["progress"])
-        except KeyError:
-            if "xmlui" in data:
-                ui = xmlui_manager.create(self.host, data["xmlui"])
-                await ui.show()
-            else:
-                self.disp(_("Can't download file"), error=True)
-            self.host.quit(C.EXIT_ERROR)
-
-    async def start(self):
-        try:
-            attachment = json.loads(self.args.attachment)
-        except json.JSONDecodeError:
-            attachment = {"uri": self.args.attachment}
-        dest_file = self.args.dest_file
-        if not dest_file:
-            try:
-                dest_file = attachment["name"].replace("/", "-").strip()
-            except KeyError:
-                try:
-                    dest_file = Path(urlparse(attachment["uri"]).path).name.strip()
-                except KeyError:
-                    pass
-            if not dest_file:
-                dest_file = "downloaded_file"
-
-        dest_file = Path(dest_file).expanduser().resolve()
-        if dest_file.exists() and not self.args.force:
-            message = _("File {path} already exists! Do you want to overwrite?").format(
-                path=dest_file
-            )
-            await self.host.confirm_or_quit(message, _("file download cancelled"))
-
-        options = {}
-
-        try:
-            download_data_s = await self.host.bridge.file_download(
-                data_format.serialise(attachment),
-                str(dest_file),
-                data_format.serialise(options),
-                self.profile,
-            )
-            download_data = data_format.deserialise(download_data_s)
-        except Exception as e:
-            self.disp(f"error while trying to download a file: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.got_id(download_data)
-
-
-class Upload(base.CommandBase):
-    def __init__(self, host):
-        super(Upload, self).__init__(
-            host, "upload", use_progress=True, use_verbose=True, help=_("upload a file")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("encrypt file using AES-GCM"),
-        )
-        self.parser.add_argument("file", type=str, help=_("file to upload"))
-        self.parser.add_argument(
-            "jid",
-            nargs="?",
-            help=_("jid of upload component (nothing to autodetect)"),
-        )
-        self.parser.add_argument(
-            "--ignore-tls-errors",
-            action="store_true",
-            help=_(r"ignore invalide TLS certificate (/!\ Dangerous /!\)"),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("File upload started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("File uploaded successfully"), 2)
-        try:
-            url = metadata["url"]
-        except KeyError:
-            self.disp("download URL not found in metadata")
-        else:
-            self.disp(_("URL to retrieve the file:"), 1)
-            # XXX: url is displayed alone on a line to make parsing easier
-            self.disp(url)
-
-    async def on_progress_error(self, error_msg):
-        self.disp(_("Error while uploading file: {}").format(error_msg), error=True)
-
-    async def got_id(self, data, file_):
-        """Called when a progress id has been received
-
-        @param pid(unicode): progress id
-        @param file_(str): file path
-        """
-        try:
-            await self.set_progress_id(data["progress"])
-        except KeyError:
-            if "xmlui" in data:
-                ui = xmlui_manager.create(self.host, data["xmlui"])
-                await ui.show()
-            else:
-                self.disp(_("Can't upload file"), error=True)
-            self.host.quit(C.EXIT_ERROR)
-
-    async def start(self):
-        file_ = self.args.file
-        if not os.path.exists(file_):
-            self.disp(
-                _("file {file_} doesn't exist !").format(file_=repr(file_)), error=True
-            )
-            self.host.quit(C.EXIT_BAD_ARG)
-        if os.path.isdir(file_):
-            self.disp(_("{file_} is a dir! Can't upload a dir").format(file_=repr(file_)))
-            self.host.quit(C.EXIT_BAD_ARG)
-
-        if self.args.jid is None:
-            self.full_dest_jid = ""
-        else:
-            self.full_dest_jid = await self.host.get_full_jid(self.args.jid)
-
-        options = {}
-        if self.args.ignore_tls_errors:
-            options["ignore_tls_errors"] = True
-        if self.args.encrypt:
-            options["encryption"] = C.ENC_AES_GCM
-
-        path = os.path.abspath(file_)
-        try:
-            upload_data = await self.host.bridge.file_upload(
-                path,
-                "",
-                self.full_dest_jid,
-                data_format.serialise(options),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"error while trying to upload a file: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.got_id(upload_data, file_)
-
-
-class ShareAffiliationsSet(base.CommandBase):
-    def __init__(self, host):
-        super(ShareAffiliationsSet, self).__init__(
-            host,
-            "set",
-            use_output=C.OUTPUT_DICT,
-            help=_("set affiliations for a shared file/directory"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            default="",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "-a",
-            "--affiliation",
-            dest="affiliations",
-            metavar=("JID", "AFFILIATION"),
-            required=True,
-            action="append",
-            nargs=2,
-            help=_("entity/affiliation couple(s)"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of file sharing entity"),
-        )
-
-    async def start(self):
-        affiliations = dict(self.args.affiliations)
-        try:
-            affiliations = await self.host.bridge.fis_affiliations_set(
-                self.args.jid,
-                self.args.namespace,
-                self.args.path,
-                affiliations,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class ShareAffiliationsGet(base.CommandBase):
-    def __init__(self, host):
-        super(ShareAffiliationsGet, self).__init__(
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            help=_("retrieve affiliations of a shared file/directory"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            default="",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of sharing entity"),
-        )
-
-    async def start(self):
-        try:
-            affiliations = await self.host.bridge.fis_affiliations_get(
-                self.args.jid,
-                self.args.namespace,
-                self.args.path,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(affiliations)
-            self.host.quit()
-
-
-class ShareAffiliations(base.CommandBase):
-    subcommands = (ShareAffiliationsGet, ShareAffiliationsSet)
-
-    def __init__(self, host):
-        super(ShareAffiliations, self).__init__(
-            host, "affiliations", use_profile=False, help=_("affiliations management")
-        )
-
-
-class ShareConfigurationSet(base.CommandBase):
-    def __init__(self, host):
-        super(ShareConfigurationSet, self).__init__(
-            host,
-            "set",
-            use_output=C.OUTPUT_DICT,
-            help=_("set configuration for a shared file/directory"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            default="",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            required=True,
-            metavar=("KEY", "VALUE"),
-            help=_("configuration field to set (required)"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of file sharing entity"),
-        )
-
-    async def start(self):
-        configuration = dict(self.args.fields)
-        try:
-            configuration = await self.host.bridge.fis_configuration_set(
-                self.args.jid,
-                self.args.namespace,
-                self.args.path,
-                configuration,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set configuration: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class ShareConfigurationGet(base.CommandBase):
-    def __init__(self, host):
-        super(ShareConfigurationGet, self).__init__(
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            help=_("retrieve configuration of a shared file/directory"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            default="",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of sharing entity"),
-        )
-
-    async def start(self):
-        try:
-            configuration = await self.host.bridge.fis_configuration_get(
-                self.args.jid,
-                self.args.namespace,
-                self.args.path,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get configuration: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(configuration)
-            self.host.quit()
-
-
-class ShareConfiguration(base.CommandBase):
-    subcommands = (ShareConfigurationGet, ShareConfigurationSet)
-
-    def __init__(self, host):
-        super(ShareConfiguration, self).__init__(
-            host,
-            "configuration",
-            use_profile=False,
-            help=_("file sharing node configuration"),
-        )
-
-
-class ShareList(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        super(ShareList, self).__init__(
-            host,
-            "list",
-            use_output=C.OUTPUT_LIST_DICT,
-            extra_outputs=extra_outputs,
-            help=_("retrieve files shared by an entity"),
-            use_verbose=True,
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-d",
-            "--path",
-            default="",
-            help=_("path to the directory containing the files"),
-        )
-        self.parser.add_argument(
-            "jid",
-            nargs="?",
-            default="",
-            help=_("jid of sharing entity (nothing to check our own jid)"),
-        )
-
-    def _name_filter(self, name, row):
-        if row.type == C.FILE_TYPE_DIRECTORY:
-            return A.color(C.A_DIRECTORY, name)
-        elif row.type == C.FILE_TYPE_FILE:
-            return A.color(C.A_FILE, name)
-        else:
-            self.disp(_("unknown file type: {type}").format(type=row.type), error=True)
-            return name
-
-    def _size_filter(self, size, row):
-        if not size:
-            return ""
-        return A.color(A.BOLD, utils.get_human_size(size))
-
-    def default_output(self, files_data):
-        """display files a way similar to ls"""
-        files_data.sort(key=lambda d: d["name"].lower())
-        show_header = False
-        if self.verbosity == 0:
-            keys = headers = ("name", "type")
-        elif self.verbosity == 1:
-            keys = headers = ("name", "type", "size")
-        elif self.verbosity > 1:
-            show_header = True
-            keys = ("name", "type", "size", "file_hash")
-            headers = ("name", "type", "size", "hash")
-        table = common.Table.from_list_dict(
-            self.host,
-            files_data,
-            keys=keys,
-            headers=headers,
-            filters={"name": self._name_filter, "size": self._size_filter},
-            defaults={"size": "", "file_hash": ""},
-        )
-        table.display_blank(show_header=show_header, hide_cols=["type"])
-
-    async def start(self):
-        try:
-            files_data = await self.host.bridge.fis_list(
-                self.args.jid,
-                self.args.path,
-                {},
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't retrieve shared files: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        await self.output(files_data)
-        self.host.quit()
-
-
-class SharePath(base.CommandBase):
-    def __init__(self, host):
-        super(SharePath, self).__init__(
-            host, "path", help=_("share a file or directory"), use_verbose=True
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help=_("virtual name to use (default: use directory/file name)"),
-        )
-        perm_group = self.parser.add_mutually_exclusive_group()
-        perm_group.add_argument(
-            "-j",
-            "--jid",
-            metavar="JID",
-            action="append",
-            dest="jids",
-            default=[],
-            help=_("jid of contacts allowed to retrieve the files"),
-        )
-        perm_group.add_argument(
-            "--public",
-            action="store_true",
-            help=_(
-                r"share publicly the file(s) (/!\ *everybody* will be able to access "
-                r"them)"
-            ),
-        )
-        self.parser.add_argument(
-            "path",
-            help=_("path to a file or directory to share"),
-        )
-
-    async def start(self):
-        self.path = os.path.abspath(self.args.path)
-        if self.args.public:
-            access = {"read": {"type": "public"}}
-        else:
-            jids = self.args.jids
-            if jids:
-                access = {"read": {"type": "whitelist", "jids": jids}}
-            else:
-                access = {}
-        try:
-            name = await self.host.bridge.fis_share_path(
-                self.args.name,
-                self.path,
-                json.dumps(access, ensure_ascii=False),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't share path: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                _('{path} shared under the name "{name}"').format(
-                    path=self.path, name=name
-                )
-            )
-            self.host.quit()
-
-
-class ShareInvite(base.CommandBase):
-    def __init__(self, host):
-        super(ShareInvite, self).__init__(
-            host, "invite", help=_("send invitation for a shared repository")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help=_("name of the repository"),
-        )
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            default="",
-            help=_("namespace of the repository"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--path",
-            help=_("path to the repository"),
-        )
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            choices=["files", "photos"],
-            default="files",
-            help=_("type of the repository"),
-        )
-        self.parser.add_argument(
-            "-T",
-            "--thumbnail",
-            help=_("https URL of a image to use as thumbnail"),
-        )
-        self.parser.add_argument(
-            "service",
-            help=_("jid of the file sharing service hosting the repository"),
-        )
-        self.parser.add_argument(
-            "jid",
-            help=_("jid of the person to invite"),
-        )
-
-    async def start(self):
-        self.path = os.path.normpath(self.args.path) if self.args.path else ""
-        extra = {}
-        if self.args.thumbnail is not None:
-            if not self.args.thumbnail.startswith("http"):
-                self.parser.error(_("only http(s) links are allowed with --thumbnail"))
-            else:
-                extra["thumb_url"] = self.args.thumbnail
-        try:
-            await self.host.bridge.fis_invite(
-                self.args.jid,
-                self.args.service,
-                self.args.type,
-                self.args.namespace,
-                self.path,
-                self.args.name,
-                data_format.serialise(extra),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't send invitation: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("invitation sent to {jid}").format(jid=self.args.jid))
-            self.host.quit()
-
-
-class Share(base.CommandBase):
-    subcommands = (
-        ShareList,
-        SharePath,
-        ShareInvite,
-        ShareAffiliations,
-        ShareConfiguration,
-    )
-
-    def __init__(self, host):
-        super(Share, self).__init__(
-            host, "share", use_profile=False, help=_("files sharing management")
-        )
-
-
-class File(base.CommandBase):
-    subcommands = (Send, Request, Receive, Get, Upload, Share)
-
-    def __init__(self, host):
-        super(File, self).__init__(
-            host, "file", use_profile=False, help=_("files sending/receiving/management")
-        )
--- a/sat_frontends/jp/cmd_forums.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,181 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.jp import common
-from libervia.backend.tools.common.ansi import ANSI as A
-import codecs
-import json
-
-__commands__ = ["Forums"]
-
-FORUMS_TMP_DIR = "forums"
-
-
-class Edit(base.CommandBase, common.BaseEdit):
-    use_items = False
-
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "edit",
-            use_pubsub=True,
-            use_draft=True,
-            use_verbose=True,
-            help=_("edit forums"),
-        )
-        common.BaseEdit.__init__(self, self.host, FORUMS_TMP_DIR)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            default="",
-            help=_("forum key (DEFAULT: default forums)"),
-        )
-
-    def get_tmp_suff(self):
-        """return suffix used for content file"""
-        return "json"
-
-    async def publish(self, forums_raw):
-        try:
-            await self.host.bridge.forums_set(
-                forums_raw,
-                self.args.service,
-                self.args.node,
-                self.args.key,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set forums: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("forums have been edited"), 1)
-            self.host.quit()
-
-    async def start(self):
-        try:
-            forums_json = await self.host.bridge.forums_get(
-                self.args.service,
-                self.args.node,
-                self.args.key,
-                self.profile,
-            )
-        except Exception as e:
-            if e.classname == "NotFound":
-                forums_json = ""
-            else:
-                self.disp(f"can't get node configuration: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        content_file_obj, content_file_path = self.get_tmp_file()
-        forums_json = forums_json.strip()
-        if forums_json:
-            # we loads and dumps to have pretty printed json
-            forums = json.loads(forums_json)
-            # cf. https://stackoverflow.com/a/18337754
-            f = codecs.getwriter("utf-8")(content_file_obj)
-            json.dump(forums, f, ensure_ascii=False, indent=4)
-            content_file_obj.seek(0)
-        await self.run_editor("forums_editor_args", content_file_path, content_file_obj)
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_COMPLEX,
-            extra_outputs=extra_outputs,
-            use_pubsub=True,
-            use_verbose=True,
-            help=_("get forums structure"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            default="",
-            help=_("forum key (DEFAULT: default forums)"),
-        )
-
-    def default_output(self, forums, level=0):
-        for forum in forums:
-            keys = list(forum.keys())
-            keys.sort()
-            try:
-                keys.remove("title")
-            except ValueError:
-                pass
-            else:
-                keys.insert(0, "title")
-            try:
-                keys.remove("sub-forums")
-            except ValueError:
-                pass
-            else:
-                keys.append("sub-forums")
-
-            for key in keys:
-                value = forum[key]
-                if key == "sub-forums":
-                    self.default_output(value, level + 1)
-                else:
-                    if self.host.verbosity < 1 and key != "title":
-                        continue
-                    head_color = C.A_LEVEL_COLORS[level % len(C.A_LEVEL_COLORS)]
-                    self.disp(
-                        A.color(level * 4 * " ", head_color, key, A.RESET, ": ", value)
-                    )
-
-    async def start(self):
-        try:
-            forums_raw = await self.host.bridge.forums_get(
-                self.args.service,
-                self.args.node,
-                self.args.key,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get forums: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if not forums_raw:
-                self.disp(_("no schema found"), 1)
-                self.host.quit(1)
-            forums = json.loads(forums_raw)
-            await self.output(forums)
-            self.host.quit()
-
-
-class Forums(base.CommandBase):
-    subcommands = (Get, Edit)
-
-    def __init__(self, host):
-        super(Forums, self).__init__(
-            host, "forums", use_profile=False, help=_("Forums structure edition")
-        )
--- a/sat_frontends/jp/cmd_identity.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,111 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.tools.common import data_format
-
-__commands__ = ["Identity"]
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            use_verbose=True,
-            help=_("get identity data"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--no-cache", action="store_true", help=_("do no use cached values")
-        )
-        self.parser.add_argument(
-            "jid", help=_("entity to check")
-        )
-
-    async def start(self):
-        jid_ = (await self.host.check_jids([self.args.jid]))[0]
-        try:
-            data = await self.host.bridge.identity_get(
-                jid_,
-                [],
-                not self.args.no_cache,
-                self.profile
-            )
-        except Exception as e:
-            self.disp(f"can't get identity data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            data = data_format.deserialise(data)
-            await self.output(data)
-            self.host.quit()
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        super(Set, self).__init__(host, "set", help=_("update identity data"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-n",
-            "--nickname",
-            action="append",
-            metavar="NICKNAME",
-            dest="nicknames",
-            help=_("nicknames of the entity"),
-        )
-        self.parser.add_argument(
-            "-d",
-            "--description",
-            help=_("description of the entity"),
-        )
-
-    async def start(self):
-        id_data = {}
-        for field in ("nicknames", "description"):
-            value = getattr(self.args, field)
-            if value is not None:
-                id_data[field] = value
-        if not id_data:
-            self.parser.error("At least one metadata must be set")
-        try:
-            self.host.bridge.identity_set(
-                data_format.serialise(id_data),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set identity data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Identity(base.CommandBase):
-    subcommands = (Get, Set)
-
-    def __init__(self, host):
-        super(Identity, self).__init__(
-            host, "identity", use_profile=False, help=_("identity management")
-        )
--- a/sat_frontends/jp/cmd_info.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,386 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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 pprint import pformat
-
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format, date_utils
-from libervia.backend.tools.common.ansi import ANSI as A
-from sat_frontends.jp import common
-from sat_frontends.jp.constants import Const as C
-
-from . import base
-
-__commands__ = ["Info"]
-
-
-class Disco(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        super(Disco, self).__init__(
-            host,
-            "disco",
-            use_output="complex",
-            extra_outputs=extra_outputs,
-            help=_("service discovery"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("jid", help=_("entity to discover"))
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            type=str,
-            choices=("infos", "items", "both", "external", "all"),
-            default="all",
-            help=_("type of data to discover"),
-        )
-        self.parser.add_argument("-n", "--node", default="", help=_("node to use"))
-        self.parser.add_argument(
-            "-C",
-            "--no-cache",
-            dest="use_cache",
-            action="store_false",
-            help=_("ignore cache"),
-        )
-
-    def default_output(self, data):
-        features = data.get("features", [])
-        identities = data.get("identities", [])
-        extensions = data.get("extensions", {})
-        items = data.get("items", [])
-        external = data.get("external", [])
-
-        identities_table = common.Table(
-            self.host,
-            identities,
-            headers=(_("category"), _("type"), _("name")),
-            use_buffer=True,
-        )
-
-        extensions_tpl = []
-        extensions_types = list(extensions.keys())
-        extensions_types.sort()
-        for type_ in extensions_types:
-            fields = []
-            for field in extensions[type_]:
-                field_lines = []
-                data, values = field
-                data_keys = list(data.keys())
-                data_keys.sort()
-                for key in data_keys:
-                    field_lines.append(
-                        A.color("\t", C.A_SUBHEADER, key, A.RESET, ": ", data[key])
-                    )
-                if len(values) == 1:
-                    field_lines.append(
-                        A.color(
-                            "\t",
-                            C.A_SUBHEADER,
-                            "value",
-                            A.RESET,
-                            ": ",
-                            values[0] or (A.BOLD + "UNSET"),
-                        )
-                    )
-                elif len(values) > 1:
-                    field_lines.append(
-                        A.color("\t", C.A_SUBHEADER, "values", A.RESET, ": ")
-                    )
-
-                    for value in values:
-                        field_lines.append(A.color("\t  - ", A.BOLD, value))
-                fields.append("\n".join(field_lines))
-            extensions_tpl.append(
-                "{type_}\n{fields}".format(type_=type_, fields="\n\n".join(fields))
-            )
-
-        items_table = common.Table(
-            self.host, items, headers=(_("entity"), _("node"), _("name")), use_buffer=True
-        )
-
-        template = []
-        fmt_kwargs = {}
-        if features:
-            template.append(A.color(C.A_HEADER, _("Features")) + "\n\n{features}")
-        if identities:
-            template.append(A.color(C.A_HEADER, _("Identities")) + "\n\n{identities}")
-        if extensions:
-            template.append(A.color(C.A_HEADER, _("Extensions")) + "\n\n{extensions}")
-        if items:
-            template.append(A.color(C.A_HEADER, _("Items")) + "\n\n{items}")
-        if external:
-            fmt_lines = []
-            for e in external:
-                data = {k: e[k] for k in sorted(e)}
-                host = data.pop("host")
-                type_ = data.pop("type")
-                fmt_lines.append(A.color(
-                    "\t",
-                    C.A_SUBHEADER,
-                    host,
-                    " ",
-                    A.RESET,
-                    "[",
-                    C.A_LEVEL_COLORS[1],
-                    type_,
-                    A.RESET,
-                    "]",
-                ))
-                extended = data.pop("extended", None)
-                for key, value in data.items():
-                    fmt_lines.append(A.color(
-                        "\t\t",
-                        C.A_LEVEL_COLORS[2],
-                        f"{key}: ",
-                        C.A_LEVEL_COLORS[3],
-                        str(value)
-                    ))
-                if extended:
-                    fmt_lines.append(A.color(
-                        "\t\t",
-                        C.A_HEADER,
-                        "extended",
-                    ))
-                    nb_extended = len(extended)
-                    for idx, form_data in enumerate(extended):
-                        namespace = form_data.get("namespace")
-                        if namespace:
-                            fmt_lines.append(A.color(
-                                "\t\t",
-                                C.A_LEVEL_COLORS[2],
-                                "namespace: ",
-                                C.A_LEVEL_COLORS[3],
-                                A.BOLD,
-                                namespace
-                            ))
-                        for field_data in form_data["fields"]:
-                            name = field_data.get("name")
-                            if not name:
-                                continue
-                            field_type = field_data.get("type")
-                            if "multi" in field_type:
-                                value = ", ".join(field_data.get("values") or [])
-                            else:
-                                value = field_data.get("value")
-                                if value is None:
-                                    continue
-                                if field_type == "boolean":
-                                    value = C.bool(value)
-                            fmt_lines.append(A.color(
-                                "\t\t",
-                                C.A_LEVEL_COLORS[2],
-                                f"{name}: ",
-                                C.A_LEVEL_COLORS[3],
-                                A.BOLD,
-                                str(value)
-                            ))
-                        if nb_extended>1 and idx < nb_extended-1:
-                            fmt_lines.append("\n")
-
-                fmt_lines.append("\n")
-
-            template.append(
-                A.color(C.A_HEADER, _("External")) + "\n\n{external_formatted}"
-            )
-            fmt_kwargs["external_formatted"] = "\n".join(fmt_lines)
-
-        print(
-            "\n\n".join(template).format(
-                features="\n".join(features),
-                identities=identities_table.display().string,
-                extensions="\n".join(extensions_tpl),
-                items=items_table.display().string,
-                **fmt_kwargs,
-            )
-        )
-
-    async def start(self):
-        infos_requested = self.args.type in ("infos", "both", "all")
-        items_requested = self.args.type in ("items", "both", "all")
-        exter_requested = self.args.type in ("external", "all")
-        if self.args.node:
-            if self.args.type == "external":
-                self.parser.error(
-                    '--node can\'t be used with discovery of external services '
-                    '(--type="external")'
-                )
-            else:
-                exter_requested = False
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-        data = {}
-
-        # infos
-        if infos_requested:
-            try:
-                infos = await self.host.bridge.disco_infos(
-                    jid,
-                    node=self.args.node,
-                    use_cache=self.args.use_cache,
-                    profile_key=self.host.profile,
-                )
-            except Exception as e:
-                self.disp(_("error while doing discovery: {e}").format(e=e), error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            else:
-                features, identities, extensions = infos
-                features.sort()
-                identities.sort(key=lambda identity: identity[2])
-                data.update(
-                    {"features": features, "identities": identities, "extensions": extensions}
-                )
-
-        # items
-        if items_requested:
-            try:
-                items = await self.host.bridge.disco_items(
-                    jid,
-                    node=self.args.node,
-                    use_cache=self.args.use_cache,
-                    profile_key=self.host.profile,
-                )
-            except Exception as e:
-                self.disp(_("error while doing discovery: {e}").format(e=e), error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                items.sort(key=lambda item: item[2])
-                data["items"] = items
-
-        # external
-        if exter_requested:
-            try:
-                ext_services_s = await self.host.bridge.external_disco_get(
-                    jid,
-                    self.host.profile,
-                )
-            except Exception as e:
-                self.disp(
-                    _("error while doing external service discovery: {e}").format(e=e),
-                    error=True
-                )
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                data["external"] = data_format.deserialise(
-                    ext_services_s, type_check=list
-                )
-
-        # output
-        await self.output(data)
-        self.host.quit()
-
-
-class Version(base.CommandBase):
-    def __init__(self, host):
-        super(Version, self).__init__(host, "version", help=_("software version"))
-
-    def add_parser_options(self):
-        self.parser.add_argument("jid", type=str, help=_("Entity to request"))
-
-    async def start(self):
-        jids = await self.host.check_jids([self.args.jid])
-        jid = jids[0]
-        try:
-            data = await self.host.bridge.software_version_get(jid, self.host.profile)
-        except Exception as e:
-            self.disp(_("error while trying to get version: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            infos = []
-            name, version, os = data
-            if name:
-                infos.append(_("Software name: {name}").format(name=name))
-            if version:
-                infos.append(_("Software version: {version}").format(version=version))
-            if os:
-                infos.append(_("Operating System: {os}").format(os=os))
-
-            print("\n".join(infos))
-            self.host.quit()
-
-
-class Session(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        super(Session, self).__init__(
-            host,
-            "session",
-            use_output="dict",
-            extra_outputs=extra_outputs,
-            help=_("running session"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def default_output(self, data):
-        started = data["started"]
-        data["started"] = "{short} (UTC, {relative})".format(
-            short=date_utils.date_fmt(started),
-            relative=date_utils.date_fmt(started, "relative"),
-        )
-        await self.host.output(C.OUTPUT_DICT, "simple", {}, data)
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.session_infos_get(self.host.profile)
-        except Exception as e:
-            self.disp(_("Error getting session infos: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(data)
-            self.host.quit()
-
-
-class Devices(base.CommandBase):
-    def __init__(self, host):
-        super(Devices, self).__init__(
-            host, "devices", use_output=C.OUTPUT_LIST_DICT, help=_("devices of an entity")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid", type=str, nargs="?", default="", help=_("Entity to request")
-        )
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.devices_infos_get(
-                self.args.jid, self.host.profile
-            )
-        except Exception as e:
-            self.disp(_("Error getting devices infos: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            data = data_format.deserialise(data, type_check=list)
-            await self.output(data)
-            self.host.quit()
-
-
-class Info(base.CommandBase):
-    subcommands = (Disco, Version, Session, Devices)
-
-    def __init__(self, host):
-        super(Info, self).__init__(
-            host,
-            "info",
-            use_profile=False,
-            help=_("Get various pieces of information on entities"),
-        )
--- a/sat_frontends/jp/cmd_input.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,350 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-
-import subprocess
-import argparse
-import sys
-import shlex
-import asyncio
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.tools.common.ansi import ANSI as A
-
-__commands__ = ["Input"]
-OPT_STDIN = "stdin"
-OPT_SHORT = "short"
-OPT_LONG = "long"
-OPT_POS = "positional"
-OPT_IGNORE = "ignore"
-OPT_TYPES = (OPT_STDIN, OPT_SHORT, OPT_LONG, OPT_POS, OPT_IGNORE)
-OPT_EMPTY_SKIP = "skip"
-OPT_EMPTY_IGNORE = "ignore"
-OPT_EMPTY_CHOICES = (OPT_EMPTY_SKIP, OPT_EMPTY_IGNORE)
-
-
-class InputCommon(base.CommandBase):
-    def __init__(self, host, name, help):
-        base.CommandBase.__init__(
-            self, host, name, use_verbose=True, use_profile=False, help=help
-        )
-        self.idx = 0
-        self.reset()
-
-    def reset(self):
-        self.args_idx = 0
-        self._stdin = []
-        self._opts = []
-        self._pos = []
-        self._values_ori = []
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--encoding", default="utf-8", help=_("encoding of the input data")
-        )
-        self.parser.add_argument(
-            "-i",
-            "--stdin",
-            action="append_const",
-            const=(OPT_STDIN, None),
-            dest="arguments",
-            help=_("standard input"),
-        )
-        self.parser.add_argument(
-            "-s",
-            "--short",
-            type=self.opt(OPT_SHORT),
-            action="append",
-            dest="arguments",
-            help=_("short option"),
-        )
-        self.parser.add_argument(
-            "-l",
-            "--long",
-            type=self.opt(OPT_LONG),
-            action="append",
-            dest="arguments",
-            help=_("long option"),
-        )
-        self.parser.add_argument(
-            "-p",
-            "--positional",
-            type=self.opt(OPT_POS),
-            action="append",
-            dest="arguments",
-            help=_("positional argument"),
-        )
-        self.parser.add_argument(
-            "-x",
-            "--ignore",
-            action="append_const",
-            const=(OPT_IGNORE, None),
-            dest="arguments",
-            help=_("ignore value"),
-        )
-        self.parser.add_argument(
-            "-D",
-            "--debug",
-            action="store_true",
-            help=_("don't actually run commands but echo what would be launched"),
-        )
-        self.parser.add_argument(
-            "--log", type=argparse.FileType("w"), help=_("log stdout to FILE")
-        )
-        self.parser.add_argument(
-            "--log-err", type=argparse.FileType("w"), help=_("log stderr to FILE")
-        )
-        self.parser.add_argument("command", nargs=argparse.REMAINDER)
-
-    def opt(self, type_):
-        return lambda s: (type_, s)
-
-    def add_value(self, value):
-        """add a parsed value according to arguments sequence"""
-        self._values_ori.append(value)
-        arguments = self.args.arguments
-        try:
-            arg_type, arg_name = arguments[self.args_idx]
-        except IndexError:
-            self.disp(
-                _("arguments in input data and in arguments sequence don't match"),
-                error=True,
-            )
-            self.host.quit(C.EXIT_DATA_ERROR)
-        self.args_idx += 1
-        while self.args_idx < len(arguments):
-            next_arg = arguments[self.args_idx]
-            if next_arg[0] not in OPT_TYPES:
-                # value will not be used if False or None, so we skip filter
-                if value not in (False, None):
-                    # we have a filter
-                    filter_type, filter_arg = arguments[self.args_idx]
-                    value = self.filter(filter_type, filter_arg, value)
-            else:
-                break
-            self.args_idx += 1
-
-        if value is None:
-            # we ignore this argument
-            return
-
-        if value is False:
-            # we skip the whole row
-            if self.args.debug:
-                self.disp(
-                    A.color(
-                        C.A_SUBHEADER,
-                        _("values: "),
-                        A.RESET,
-                        ", ".join(self._values_ori),
-                    ),
-                    2,
-                )
-                self.disp(A.color(A.BOLD, _("**SKIPPING**\n")))
-            self.reset()
-            self.idx += 1
-            raise exceptions.CancelError
-
-        if not isinstance(value, list):
-            value = [value]
-
-        for v in value:
-            if arg_type == OPT_STDIN:
-                self._stdin.append(v)
-            elif arg_type == OPT_SHORT:
-                self._opts.append("-{}".format(arg_name))
-                self._opts.append(v)
-            elif arg_type == OPT_LONG:
-                self._opts.append("--{}".format(arg_name))
-                self._opts.append(v)
-            elif arg_type == OPT_POS:
-                self._pos.append(v)
-            elif arg_type == OPT_IGNORE:
-                pass
-            else:
-                self.parser.error(
-                    _(
-                        "Invalid argument, an option type is expected, got {type_}:{name}"
-                    ).format(type_=arg_type, name=arg_name)
-                )
-
-    async def runCommand(self):
-        """run requested command with parsed arguments"""
-        if self.args_idx != len(self.args.arguments):
-            self.disp(
-                _("arguments in input data and in arguments sequence don't match"),
-                error=True,
-            )
-            self.host.quit(C.EXIT_DATA_ERROR)
-        end = '\n' if self.args.debug else ' '
-        self.disp(
-            A.color(C.A_HEADER, _("command {idx}").format(idx=self.idx)),
-            end = end,
-        )
-        stdin = "".join(self._stdin)
-        if self.args.debug:
-            self.disp(
-                A.color(
-                    C.A_SUBHEADER,
-                    _("values: "),
-                    A.RESET,
-                    ", ".join([shlex.quote(a) for a in self._values_ori])
-                ),
-                2,
-            )
-
-            if stdin:
-                self.disp(A.color(C.A_SUBHEADER, "--- STDIN ---"))
-                self.disp(stdin)
-                self.disp(A.color(C.A_SUBHEADER, "-------------"))
-
-            self.disp(
-                "{indent}{prog} {static} {options} {positionals}".format(
-                    indent=4 * " ",
-                    prog=sys.argv[0],
-                    static=" ".join(self.args.command),
-                    options=" ".join(shlex.quote(o) for o in self._opts),
-                    positionals=" ".join(shlex.quote(p) for p in self._pos),
-                )
-            )
-            self.disp("\n")
-        else:
-            self.disp(" (" + ", ".join(self._values_ori) + ")", 2, end=' ')
-            args = [sys.argv[0]] + self.args.command + self._opts + self._pos
-            p = await asyncio.create_subprocess_exec(
-                *args,
-                stdin=subprocess.PIPE,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.PIPE,
-            )
-            stdout, stderr = await p.communicate(stdin.encode('utf-8'))
-            log = self.args.log
-            log_err = self.args.log_err
-            log_tpl = "{command}\n{buff}\n\n"
-            if log:
-                log.write(log_tpl.format(
-                    command=" ".join(shlex.quote(a) for a in args),
-                    buff=stdout.decode('utf-8', 'replace')))
-            if log_err:
-                log_err.write(log_tpl.format(
-                    command=" ".join(shlex.quote(a) for a in args),
-                    buff=stderr.decode('utf-8', 'replace')))
-            ret = p.returncode
-            if ret == 0:
-                self.disp(A.color(C.A_SUCCESS, _("OK")))
-            else:
-                self.disp(A.color(C.A_FAILURE, _("FAILED")))
-
-        self.reset()
-        self.idx += 1
-
-    def filter(self, filter_type, filter_arg, value):
-        """change input value
-
-        @param filter_type(unicode): name of the filter
-        @param filter_arg(unicode, None): argument of the filter
-        @param value(unicode): value to filter
-        @return (unicode, False, None): modified value
-            False to skip the whole row
-            None to ignore this argument (but continue row with other ones)
-        """
-        raise NotImplementedError
-
-
-class Csv(InputCommon):
-    def __init__(self, host):
-        super(Csv, self).__init__(host, "csv", _("comma-separated values"))
-
-    def add_parser_options(self):
-        InputCommon.add_parser_options(self)
-        self.parser.add_argument(
-            "-r",
-            "--row",
-            type=int,
-            default=0,
-            help=_("starting row (previous ones will be ignored)"),
-        )
-        self.parser.add_argument(
-            "-S",
-            "--split",
-            action="append_const",
-            const=("split", None),
-            dest="arguments",
-            help=_("split value in several options"),
-        )
-        self.parser.add_argument(
-            "-E",
-            "--empty",
-            action="append",
-            type=self.opt("empty"),
-            dest="arguments",
-            help=_("action to do on empty value ({choices})").format(
-                choices=", ".join(OPT_EMPTY_CHOICES)
-            ),
-        )
-
-    def filter(self, filter_type, filter_arg, value):
-        if filter_type == "split":
-            return value.split()
-        elif filter_type == "empty":
-            if filter_arg == OPT_EMPTY_IGNORE:
-                return value if value else None
-            elif filter_arg == OPT_EMPTY_SKIP:
-                return value if value else False
-            else:
-                self.parser.error(
-                    _("--empty value must be one of {choices}").format(
-                        choices=", ".join(OPT_EMPTY_CHOICES)
-                    )
-                )
-
-        super(Csv, self).filter(filter_type, filter_arg, value)
-
-    async def start(self):
-        import csv
-
-        if self.args.encoding:
-            sys.stdin.reconfigure(encoding=self.args.encoding, errors="replace")
-        reader = csv.reader(sys.stdin)
-        for idx, row in enumerate(reader):
-            try:
-                if idx < self.args.row:
-                    continue
-                for value in row:
-                    self.add_value(value)
-                await self.runCommand()
-            except exceptions.CancelError:
-                #  this row has been cancelled, we skip it
-                continue
-
-        self.host.quit()
-
-
-class Input(base.CommandBase):
-    subcommands = (Csv,)
-
-    def __init__(self, host):
-        super(Input, self).__init__(
-            host,
-            "input",
-            use_profile=False,
-            help=_("launch command with external input"),
-        )
--- a/sat_frontends/jp/cmd_invitation.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,371 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import data_format
-
-__commands__ = ["Invitation"]
-
-
-class Create(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "create",
-            use_profile=False,
-            use_output=C.OUTPUT_DICT,
-            help=_("create and send an invitation"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j",
-            "--jid",
-            default="",
-            help="jid of the invitee (default: generate one)",
-        )
-        self.parser.add_argument(
-            "-P",
-            "--password",
-            default="",
-            help="password of the invitee profile/XMPP account (default: generate one)",
-        )
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help="name of the invitee",
-        )
-        self.parser.add_argument(
-            "-N",
-            "--host-name",
-            default="",
-            help="name of the host",
-        )
-        self.parser.add_argument(
-            "-e",
-            "--email",
-            action="append",
-            default=[],
-            help="email(s) to send the invitation to (if --no-email is set, email will just be saved)",
-        )
-        self.parser.add_argument(
-            "--no-email", action="store_true", help="do NOT send invitation email"
-        )
-        self.parser.add_argument(
-            "-l",
-            "--lang",
-            default="",
-            help="main language spoken by the invitee",
-        )
-        self.parser.add_argument(
-            "-u",
-            "--url",
-            default="",
-            help="template to construct the URL",
-        )
-        self.parser.add_argument(
-            "-s",
-            "--subject",
-            default="",
-            help="subject of the invitation email (default: generic subject)",
-        )
-        self.parser.add_argument(
-            "-b",
-            "--body",
-            default="",
-            help="body of the invitation email (default: generic body)",
-        )
-        self.parser.add_argument(
-            "-x",
-            "--extra",
-            metavar=("KEY", "VALUE"),
-            action="append",
-            nargs=2,
-            default=[],
-            help="extra data to associate with invitation/invitee",
-        )
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            default="",
-            help="profile doing the invitation (default: don't associate profile)",
-        )
-
-    async def start(self):
-        extra = dict(self.args.extra)
-        email = self.args.email[0] if self.args.email else None
-        emails_extra = self.args.email[1:]
-        if self.args.no_email:
-            if email:
-                extra["email"] = email
-                data_format.iter2dict("emails_extra", emails_extra)
-        else:
-            if not email:
-                self.parser.error(
-                    _("you need to specify an email address to send email invitation")
-                )
-
-        try:
-            invitation_data = await self.host.bridge.invitation_create(
-                email,
-                emails_extra,
-                self.args.jid,
-                self.args.password,
-                self.args.name,
-                self.args.host_name,
-                self.args.lang,
-                self.args.url,
-                self.args.subject,
-                self.args.body,
-                extra,
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create invitation: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(invitation_data)
-            self.host.quit(C.EXIT_OK)
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_profile=False,
-            use_output=C.OUTPUT_DICT,
-            help=_("get invitation data"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("id", help=_("invitation UUID"))
-        self.parser.add_argument(
-            "-j",
-            "--with-jid",
-            action="store_true",
-            help=_("start profile session and retrieve jid"),
-        )
-
-    async def output_data(self, data, jid_=None):
-        if jid_ is not None:
-            data["jid"] = jid_
-        await self.output(data)
-        self.host.quit()
-
-    async def start(self):
-        try:
-            invitation_data = await self.host.bridge.invitation_get(
-                self.args.id,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't get invitation data: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if not self.args.with_jid:
-            await self.output_data(invitation_data)
-        else:
-            profile = invitation_data["guest_profile"]
-            try:
-                await self.host.bridge.profile_start_session(
-                    invitation_data["password"],
-                    profile,
-                )
-            except Exception as e:
-                self.disp(msg=_("can't start session: {e}").format(e=e), error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            try:
-                jid_ = await self.host.bridge.param_get_a_async(
-                    "JabberID",
-                    "Connection",
-                    profile_key=profile,
-                )
-            except Exception as e:
-                self.disp(msg=_("can't retrieve jid: {e}").format(e=e), error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            await self.output_data(invitation_data, jid_)
-
-
-class Delete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_profile=False,
-            help=_("delete guest account"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("id", help=_("invitation UUID"))
-
-    async def start(self):
-        try:
-            await self.host.bridge.invitation_delete(
-                self.args.id,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't delete guest account: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-
-class Modify(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "modify", use_profile=False, help=_("modify existing invitation")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--replace", action="store_true", help="replace the whole data"
-        )
-        self.parser.add_argument(
-            "-n",
-            "--name",
-            default="",
-            help="name of the invitee",
-        )
-        self.parser.add_argument(
-            "-N",
-            "--host-name",
-            default="",
-            help="name of the host",
-        )
-        self.parser.add_argument(
-            "-e",
-            "--email",
-            default="",
-            help="email to send the invitation to (if --no-email is set, email will just be saved)",
-        )
-        self.parser.add_argument(
-            "-l",
-            "--lang",
-            dest="language",
-            default="",
-            help="main language spoken by the invitee",
-        )
-        self.parser.add_argument(
-            "-x",
-            "--extra",
-            metavar=("KEY", "VALUE"),
-            action="append",
-            nargs=2,
-            default=[],
-            help="extra data to associate with invitation/invitee",
-        )
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            default="",
-            help="profile doing the invitation (default: don't associate profile",
-        )
-        self.parser.add_argument("id", help=_("invitation UUID"))
-
-    async def start(self):
-        extra = dict(self.args.extra)
-        for arg_name in ("name", "host_name", "email", "language", "profile"):
-            value = getattr(self.args, arg_name)
-            if not value:
-                continue
-            if arg_name in extra:
-                self.parser.error(
-                    _(
-                        "you can't set {arg_name} in both optional argument and extra"
-                    ).format(arg_name=arg_name)
-                )
-            extra[arg_name] = value
-        try:
-            await self.host.bridge.invitation_modify(
-                self.args.id,
-                extra,
-                self.args.replace,
-            )
-        except Exception as e:
-            self.disp(f"can't modify invitation: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("invitations have been modified successfuly"))
-            self.host.quit(C.EXIT_OK)
-
-
-class List(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {"default": self.default_output}
-        base.CommandBase.__init__(
-            self,
-            host,
-            "list",
-            use_profile=False,
-            use_output=C.OUTPUT_COMPLEX,
-            extra_outputs=extra_outputs,
-            help=_("list invitations data"),
-        )
-
-    def default_output(self, data):
-        for idx, datum in enumerate(data.items()):
-            if idx:
-                self.disp("\n")
-            key, invitation_data = datum
-            self.disp(A.color(C.A_HEADER, key))
-            indent = "  "
-            for k, v in invitation_data.items():
-                self.disp(indent + A.color(C.A_SUBHEADER, k + ":") + " " + str(v))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            default=C.PROF_KEY_NONE,
-            help=_("return only invitations linked to this profile"),
-        )
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.invitation_list(
-                self.args.profile,
-            )
-        except Exception as e:
-            self.disp(f"return only invitations linked to this profile: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(data)
-            self.host.quit()
-
-
-class Invitation(base.CommandBase):
-    subcommands = (Create, Get, Delete, Modify, List)
-
-    def __init__(self, host):
-        super(Invitation, self).__init__(
-            host,
-            "invitation",
-            use_profile=False,
-            help=_("invitation of user(s) without XMPP account"),
-        )
--- a/sat_frontends/jp/cmd_list.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,351 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-
-import json
-import os
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from sat_frontends.jp import common
-from sat_frontends.jp.constants import Const as C
-from . import base
-
-__commands__ = ["List"]
-
-FIELDS_MAP = "mapping"
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_verbose=True,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS},
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            use_output=C.OUTPUT_LIST_XMLUI,
-            help=_("get lists"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
-        try:
-            lists_data = data_format.deserialise(
-                await self.host.bridge.list_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    "",
-                    self.get_pubsub_extra(),
-                    self.profile,
-                ),
-                type_check=list,
-            )
-        except Exception as e:
-            self.disp(f"can't get lists: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(lists_data[0])
-            self.host.quit(C.EXIT_OK)
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("set a list item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs="+",
-            dest="fields",
-            required=True,
-            metavar=("NAME", "VALUES"),
-            help=_("field(s) to set (required)"),
-        )
-        self.parser.add_argument(
-            "-U",
-            "--update",
-            choices=("auto", "true", "false"),
-            default="auto",
-            help=_("update existing item instead of replacing it (DEFAULT: auto)"),
-        )
-        self.parser.add_argument(
-            "item",
-            nargs="?",
-            default="",
-            help=_("id, URL of the item to update, or nothing for new item"),
-        )
-
-    async def start(self):
-        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
-        if self.args.update == "auto":
-            # we update if we have a item id specified
-            update = bool(self.args.item)
-        else:
-            update = C.bool(self.args.update)
-
-        values = {}
-
-        for field_data in self.args.fields:
-            values.setdefault(field_data[0], []).extend(field_data[1:])
-
-        extra = {"update": update}
-
-        try:
-            item_id = await self.host.bridge.list_set(
-                self.args.service,
-                self.args.node,
-                values,
-                "",
-                self.args.item,
-                data_format.serialise(extra),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set list item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(f"item {str(item_id or self.args.item)!r} set successfully")
-            self.host.quit(C.EXIT_OK)
-
-
-class Delete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_pubsub=True,
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("delete a list item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--force", action="store_true", help=_("delete without confirmation")
-        )
-        self.parser.add_argument(
-            "-N", "--notify", action="store_true", help=_("notify deletion")
-        )
-        self.parser.add_argument(
-            "item",
-            help=_("id of the item to delete"),
-        )
-
-    async def start(self):
-        await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={})
-        if not self.args.item:
-            self.parser.error(_("You need to specify a list item to delete"))
-        if not self.args.force:
-            message = _("Are you sure to delete list item {item_id} ?").format(
-                item_id=self.args.item
-            )
-            await self.host.confirm_or_quit(message, _("item deletion cancelled"))
-        try:
-            await self.host.bridge.list_delete_item(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.notify,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't delete item: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("item {item} has been deleted").format(item=self.args.item))
-            self.host.quit(C.EXIT_OK)
-
-
-class Import(base.CommandBase):
-    # TODO: factorize with blog/import
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "import",
-            use_progress=True,
-            use_verbose=True,
-            help=_("import tickets from external software/dataset"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "importer",
-            nargs="?",
-            help=_("importer name, nothing to display importers list"),
-        )
-        self.parser.add_argument(
-            "-o",
-            "--option",
-            action="append",
-            nargs=2,
-            default=[],
-            metavar=("NAME", "VALUE"),
-            help=_("importer specific options (see importer description)"),
-        )
-        self.parser.add_argument(
-            "-m",
-            "--map",
-            action="append",
-            nargs=2,
-            default=[],
-            metavar=("IMPORTED_FIELD", "DEST_FIELD"),
-            help=_(
-                "specified field in import data will be put in dest field (default: use "
-                "same field name, or ignore if it doesn't exist)"
-            ),
-        )
-        self.parser.add_argument(
-            "-s",
-            "--service",
-            default="",
-            metavar="PUBSUB_SERVICE",
-            help=_("PubSub service where the items must be uploaded (default: server)"),
-        )
-        self.parser.add_argument(
-            "-n",
-            "--node",
-            default="",
-            metavar="PUBSUB_NODE",
-            help=_(
-                "PubSub node where the items must be uploaded (default: tickets' "
-                "defaults)"
-            ),
-        )
-        self.parser.add_argument(
-            "location",
-            nargs="?",
-            help=_(
-                "importer data location (see importer description), nothing to show "
-                "importer description"
-            ),
-        )
-
-    async def on_progress_started(self, metadata):
-        self.disp(_("Tickets upload started"), 2)
-
-    async def on_progress_finished(self, metadata):
-        self.disp(_("Tickets uploaded successfully"), 2)
-
-    async def on_progress_error(self, error_msg):
-        self.disp(
-            _("Error while uploading tickets: {error_msg}").format(error_msg=error_msg),
-            error=True,
-        )
-
-    async def start(self):
-        if self.args.location is None:
-            # no location, the list of importer or description is requested
-            for name in ("option", "service", "node"):
-                if getattr(self.args, name):
-                    self.parser.error(
-                        _(
-                            "{name} argument can't be used without location argument"
-                        ).format(name=name)
-                    )
-            if self.args.importer is None:
-                self.disp(
-                    "\n".join(
-                        [
-                            f"{name}: {desc}"
-                            for name, desc in await self.host.bridge.ticketsImportList()
-                        ]
-                    )
-                )
-            else:
-                try:
-                    short_desc, long_desc = await self.host.bridge.ticketsImportDesc(
-                        self.args.importer
-                    )
-                except Exception as e:
-                    self.disp(f"can't get importer description: {e}", error=True)
-                    self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-                else:
-                    self.disp(f"{name}: {short_desc}\n\n{long_desc}")
-            self.host.quit()
-        else:
-            # we have a location, an import is requested
-
-            if self.args.progress:
-                # we use a custom progress bar template as we want a counter
-                self.pbar_template = [
-                    _("Progress: "),
-                    ["Percentage"],
-                    " ",
-                    ["Bar"],
-                    " ",
-                    ["Counter"],
-                    " ",
-                    ["ETA"],
-                ]
-
-            options = {key: value for key, value in self.args.option}
-            fields_map = dict(self.args.map)
-            if fields_map:
-                if FIELDS_MAP in options:
-                    self.parser.error(
-                        _(
-                            "fields_map must be specified either preencoded in --option or "
-                            "using --map, but not both at the same time"
-                        )
-                    )
-                options[FIELDS_MAP] = json.dumps(fields_map)
-
-            try:
-                progress_id = await self.host.bridge.ticketsImport(
-                    self.args.importer,
-                    self.args.location,
-                    options,
-                    self.args.service,
-                    self.args.node,
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(
-                    _("Error while trying to import tickets: {e}").format(e=e),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                await self.set_progress_id(progress_id)
-
-
-class List(base.CommandBase):
-    subcommands = (Get, Set, Delete, Import)
-
-    def __init__(self, host):
-        super(List, self).__init__(
-            host, "list", use_profile=False, help=_("pubsub lists handling")
-        )
--- a/sat_frontends/jp/cmd_merge_request.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,210 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-
-import os.path
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.jp import xmlui_manager
-from sat_frontends.jp import common
-
-__commands__ = ["MergeRequest"]
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("publish or update a merge request"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-i",
-            "--item",
-            default="",
-            help=_("id or URL of the request to update, or nothing for a new one"),
-        )
-        self.parser.add_argument(
-            "-r",
-            "--repository",
-            metavar="PATH",
-            default=".",
-            help=_("path of the repository (DEFAULT: current directory)"),
-        )
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("publish merge request without confirmation"),
-        )
-        self.parser.add_argument(
-            "-l",
-            "--label",
-            dest="labels",
-            action="append",
-            help=_("labels to categorize your request"),
-        )
-
-    async def start(self):
-        self.repository = os.path.expanduser(os.path.abspath(self.args.repository))
-        await common.fill_well_known_uri(self, self.repository, "merge requests")
-        if not self.args.force:
-            message = _(
-                "You are going to publish your changes to service "
-                "[{service}], are you sure ?"
-            ).format(service=self.args.service)
-            await self.host.confirm_or_quit(
-                message, _("merge request publication cancelled")
-            )
-
-        extra = {"update": True} if self.args.item else {}
-        values = {}
-        if self.args.labels is not None:
-            values["labels"] = self.args.labels
-        try:
-            published_id = await self.host.bridge.merge_request_set(
-                self.args.service,
-                self.args.node,
-                self.repository,
-                "auto",
-                values,
-                "",
-                self.args.item,
-                data_format.serialise(extra),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create merge requests: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if published_id:
-            self.disp(
-                _("Merge request published at {published_id}").format(
-                    published_id=published_id
-                )
-            )
-        else:
-            self.disp(_("Merge request published"))
-
-        self.host.quit(C.EXIT_OK)
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_verbose=True,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS},
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("get a merge request"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        await common.fill_well_known_uri(self, os.getcwd(), "merge requests", meta_map={})
-        extra = {}
-        try:
-            requests_data = data_format.deserialise(
-                await self.host.bridge.merge_requests_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    "",
-                    data_format.serialise(extra),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't get merge request: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if self.verbosity >= 1:
-            whitelist = None
-        else:
-            whitelist = {"id", "title", "body"}
-        for request_xmlui in requests_data["items"]:
-            xmlui = xmlui_manager.create(self.host, request_xmlui, whitelist=whitelist)
-            await xmlui.show(values_only=True)
-            self.disp("")
-        self.host.quit(C.EXIT_OK)
-
-
-class Import(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "import",
-            use_pubsub=True,
-            pubsub_flags={C.SINGLE_ITEM, C.ITEM},
-            pubsub_defaults={"service": _("auto"), "node": _("auto")},
-            help=_("import a merge request"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-r",
-            "--repository",
-            metavar="PATH",
-            default=".",
-            help=_("path of the repository (DEFAULT: current directory)"),
-        )
-
-    async def start(self):
-        self.repository = os.path.expanduser(os.path.abspath(self.args.repository))
-        await common.fill_well_known_uri(
-            self, self.repository, "merge requests", meta_map={}
-        )
-        extra = {}
-        try:
-            await self.host.bridge.merge_requests_import(
-                self.repository,
-                self.args.item,
-                self.args.service,
-                self.args.node,
-                extra,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't import merge request: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class MergeRequest(base.CommandBase):
-    subcommands = (Set, Get, Import)
-
-    def __init__(self, host):
-        super(MergeRequest, self).__init__(
-            host, "merge-request", use_profile=False, help=_("merge-request management")
-        )
--- a/sat_frontends/jp/cmd_message.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,327 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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 pathlib import Path
-import sys
-
-from twisted.python import filepath
-
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.utils import clean_ustr
-from sat_frontends.jp import base
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.tools import jid
-
-
-__commands__ = ["Message"]
-
-
-class Send(base.CommandBase):
-    def __init__(self, host):
-        super(Send, self).__init__(host, "send", help=_("send a message to a contact"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-l", "--lang", type=str, default="", help=_("language of the message")
-        )
-        self.parser.add_argument(
-            "-s",
-            "--separate",
-            action="store_true",
-            help=_(
-                "separate xmpp messages: send one message per line instead of one "
-                "message alone."
-            ),
-        )
-        self.parser.add_argument(
-            "-n",
-            "--new-line",
-            action="store_true",
-            help=_(
-                "add a new line at the beginning of the input"
-            ),
-        )
-        self.parser.add_argument(
-            "-S",
-            "--subject",
-            help=_("subject of the message"),
-        )
-        self.parser.add_argument(
-            "-L", "--subject-lang", type=str, default="", help=_("language of subject")
-        )
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            choices=C.MESS_TYPE_STANDARD + (C.MESS_TYPE_AUTO,),
-            default=C.MESS_TYPE_AUTO,
-            help=_("type of the message"),
-        )
-        self.parser.add_argument("-e", "--encrypt", metavar="ALGORITHM",
-                                 help=_("encrypt message using given algorithm"))
-        self.parser.add_argument(
-            "--encrypt-noreplace",
-            action="store_true",
-            help=_("don't replace encryption algorithm if an other one is already used"))
-        self.parser.add_argument(
-            "-a", "--attach", dest="attachments", action="append", metavar="FILE_PATH",
-            help=_("add a file as an attachment")
-        )
-        syntax = self.parser.add_mutually_exclusive_group()
-        syntax.add_argument("-x", "--xhtml", action="store_true", help=_("XHTML body"))
-        syntax.add_argument("-r", "--rich", action="store_true", help=_("rich body"))
-        self.parser.add_argument(
-            "jid", help=_("the destination jid")
-        )
-
-    async def send_stdin(self, dest_jid):
-        """Send incomming data on stdin to jabber contact
-
-        @param dest_jid: destination jid
-        """
-        header = "\n" if self.args.new_line else ""
-        # FIXME: stdin is not read asynchronously at the moment
-        stdin_lines = [
-            stream for stream in sys.stdin.readlines()
-        ]
-        extra = {}
-        if self.args.subject is None:
-            subject = {}
-        else:
-            subject = {self.args.subject_lang: self.args.subject}
-
-        if self.args.xhtml or self.args.rich:
-            key = "xhtml" if self.args.xhtml else "rich"
-            if self.args.lang:
-                key = f"{key}_{self.args.lang}"
-            extra[key] = clean_ustr("".join(stdin_lines))
-            stdin_lines = []
-
-        to_send = []
-
-        error = False
-
-        if self.args.separate:
-            # we send stdin in several messages
-            if header:
-                # first we sent the header
-                try:
-                    await self.host.bridge.message_send(
-                        dest_jid,
-                        {self.args.lang: header},
-                        subject,
-                        self.args.type,
-                        profile_key=self.profile,
-                    )
-                except Exception as e:
-                    self.disp(f"can't send header: {e}", error=True)
-                    error = True
-
-            to_send.extend({self.args.lang: clean_ustr(l.replace("\n", ""))}
-                           for l in stdin_lines)
-        else:
-            # we sent all in a single message
-            if not (self.args.xhtml or self.args.rich):
-                msg = {self.args.lang: header + clean_ustr("".join(stdin_lines))}
-            else:
-                msg = {}
-            to_send.append(msg)
-
-        if self.args.attachments:
-            attachments = extra[C.KEY_ATTACHMENTS] = []
-            for attachment in self.args.attachments:
-                try:
-                    file_path = str(Path(attachment).resolve(strict=True))
-                except FileNotFoundError:
-                    self.disp("file {attachment} doesn't exists, ignoring", error=True)
-                else:
-                    attachments.append({"path": file_path})
-
-        for idx, msg in enumerate(to_send):
-            if idx > 0 and C.KEY_ATTACHMENTS in extra:
-                # if we send several messages, we only want to send attachments with the
-                # first one
-                del extra[C.KEY_ATTACHMENTS]
-            try:
-                await self.host.bridge.message_send(
-                    dest_jid,
-                    msg,
-                    subject,
-                    self.args.type,
-                    data_format.serialise(extra),
-                    profile_key=self.host.profile)
-            except Exception as e:
-                self.disp(f"can't send message {msg!r}: {e}", error=True)
-                error = True
-
-        if error:
-            # at least one message sending failed
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        self.host.quit()
-
-    async def start(self):
-        if self.args.xhtml and self.args.separate:
-            self.disp(
-                "argument -s/--separate is not compatible yet with argument -x/--xhtml",
-                error=True,
-            )
-            self.host.quit(C.EXIT_BAD_ARG)
-
-        jids = await self.host.check_jids([self.args.jid])
-        jid_ = jids[0]
-
-        if self.args.encrypt_noreplace and self.args.encrypt is None:
-            self.parser.error("You need to use --encrypt if you use --encrypt-noreplace")
-
-        if self.args.encrypt is not None:
-            try:
-                namespace = await self.host.bridge.encryption_namespace_get(
-                    self.args.encrypt)
-            except Exception as e:
-                self.disp(f"can't get encryption namespace: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-            try:
-                await self.host.bridge.message_encryption_start(
-                    jid_, namespace, not self.args.encrypt_noreplace, self.profile
-                )
-            except Exception as e:
-                self.disp(f"can't start encryption session: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        await self.send_stdin(jid_)
-
-
-class Retract(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(host, "retract", help=_("retract a message"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "message_id",
-            help=_("ID of the message (internal ID)")
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.message_retract(
-                self.args.message_id,
-                self.profile
-            )
-        except Exception as e:
-            self.disp(f"can't retract message: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                "message retraction has been requested, please note that this is a "
-                "request which can't be enforced (see documentation for details).")
-            self.host.quit(C.EXIT_OK)
-
-
-class MAM(base.CommandBase):
-
-    def __init__(self, host):
-        super(MAM, self).__init__(
-            host, "mam", use_output=C.OUTPUT_MESS, use_verbose=True,
-            help=_("query archives using MAM"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-s", "--service", default="",
-            help=_("jid of the service (default: profile's server"))
-        self.parser.add_argument(
-            "-S", "--start", dest="mam_start", type=base.date_decoder,
-            help=_(
-                "start fetching archive from this date (default: from the beginning)"))
-        self.parser.add_argument(
-            "-E", "--end", dest="mam_end", type=base.date_decoder,
-            help=_("end fetching archive after this date (default: no limit)"))
-        self.parser.add_argument(
-            "-W", "--with", dest="mam_with",
-            help=_("retrieve only archives with this jid"))
-        self.parser.add_argument(
-            "-m", "--max", dest="rsm_max", type=int, default=20,
-            help=_("maximum number of items to retrieve, using RSM (default: 20))"))
-        rsm_page_group = self.parser.add_mutually_exclusive_group()
-        rsm_page_group.add_argument(
-            "-a", "--after", dest="rsm_after",
-            help=_("find page after this item"), metavar='ITEM_ID')
-        rsm_page_group.add_argument(
-            "-b", "--before", dest="rsm_before",
-            help=_("find page before this item"), metavar='ITEM_ID')
-        rsm_page_group.add_argument(
-            "--index", dest="rsm_index", type=int,
-            help=_("index of the page to retrieve"))
-
-    async def start(self):
-        extra = {}
-        if self.args.mam_start is not None:
-            extra["mam_start"] = float(self.args.mam_start)
-        if self.args.mam_end is not None:
-            extra["mam_end"] = float(self.args.mam_end)
-        if self.args.mam_with is not None:
-            extra["mam_with"] = self.args.mam_with
-        for suff in ('max', 'after', 'before', 'index'):
-            key = 'rsm_' + suff
-            value = getattr(self.args,key)
-            if value is not None:
-                extra[key] = str(value)
-        try:
-            data, metadata_s, profile = await self.host.bridge.mam_get(
-                self.args.service, data_format.serialise(extra), self.profile)
-        except Exception as e:
-            self.disp(f"can't retrieve MAM archives: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        metadata = data_format.deserialise(metadata_s)
-
-        try:
-            session_info = await self.host.bridge.session_infos_get(self.profile)
-        except Exception as e:
-            self.disp(f"can't get session infos: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        # we need to fill own_jid for message output
-        self.host.own_jid = jid.JID(session_info["jid"])
-
-        await self.output(data)
-
-        # FIXME: metadata are not displayed correctly and don't play nice with output
-        #        they should be added to output data somehow
-        if self.verbosity:
-            for value in ("rsm_first", "rsm_last", "rsm_index", "rsm_count",
-                          "mam_complete", "mam_stable"):
-                if value in metadata:
-                    label = value.split("_")[1]
-                    self.disp(A.color(
-                        C.A_HEADER, label, ': ' , A.RESET, metadata[value]))
-
-        self.host.quit()
-
-
-class Message(base.CommandBase):
-    subcommands = (Send, Retract, MAM)
-
-    def __init__(self, host):
-        super(Message, self).__init__(
-            host, "message", use_profile=False, help=_("messages handling")
-        )
--- a/sat_frontends/jp/cmd_param.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,183 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . import base
-from libervia.backend.core.i18n import _
-from .constants import Const as C
-
-__commands__ = ["Param"]
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        super(Get, self).__init__(
-            host, "get", need_connect=False, help=_("get a parameter value")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "category", nargs="?", help=_("category of the parameter")
-        )
-        self.parser.add_argument("name", nargs="?", help=_("name of the parameter"))
-        self.parser.add_argument(
-            "-a",
-            "--attribute",
-            type=str,
-            default="value",
-            help=_("name of the attribute to get"),
-        )
-        self.parser.add_argument(
-            "--security-limit", type=int, default=-1, help=_("security limit")
-        )
-
-    async def start(self):
-        if self.args.category is None:
-            categories = await self.host.bridge.params_categories_get()
-            print("\n".join(categories))
-        elif self.args.name is None:
-            try:
-                values_dict = await self.host.bridge.params_values_from_category_get_async(
-                    self.args.category, self.args.security_limit, "", "", self.profile
-                )
-            except Exception as e:
-                self.disp(
-                    _("can't find requested parameters: {e}").format(e=e), error=True
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                for name, value in values_dict.items():
-                    print(f"{name}\t{value}")
-        else:
-            try:
-                value = await self.host.bridge.param_get_a_async(
-                    self.args.name,
-                    self.args.category,
-                    self.args.attribute,
-                    self.args.security_limit,
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(
-                    _("can't find requested parameter: {e}").format(e=e), error=True
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                print(value)
-        self.host.quit()
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        super(Set, self).__init__(
-            host, "set", need_connect=False, help=_("set a parameter value")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("category", help=_("category of the parameter"))
-        self.parser.add_argument("name", help=_("name of the parameter"))
-        self.parser.add_argument("value", help=_("name of the parameter"))
-        self.parser.add_argument(
-            "--security-limit", type=int, default=-1, help=_("security limit")
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.param_set(
-                self.args.name,
-                self.args.value,
-                self.args.category,
-                self.args.security_limit,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't set requested parameter: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class SaveTemplate(base.CommandBase):
-    # FIXME: this should probably be removed, it's not used and not useful for end-user
-
-    def __init__(self, host):
-        super(SaveTemplate, self).__init__(
-            host,
-            "save",
-            use_profile=False,
-            help=_("save parameters template to xml file"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("filename", type=str, help=_("output file"))
-
-    async def start(self):
-        """Save parameters template to XML file"""
-        try:
-            await self.host.bridge.params_template_save(self.args.filename)
-        except Exception as e:
-            self.disp(_("can't save parameters to file: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                _("parameters saved to file {filename}").format(
-                    filename=self.args.filename
-                )
-            )
-            self.host.quit()
-
-
-class LoadTemplate(base.CommandBase):
-    # FIXME: this should probably be removed, it's not used and not useful for end-user
-
-    def __init__(self, host):
-        super(LoadTemplate, self).__init__(
-            host,
-            "load",
-            use_profile=False,
-            help=_("load parameters template from xml file"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("filename", type=str, help=_("input file"))
-
-    async def start(self):
-        """Load parameters template from xml file"""
-        try:
-            self.host.bridge.params_template_load(self.args.filename)
-        except Exception as e:
-            self.disp(_("can't load parameters from file: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                _("parameters loaded from file {filename}").format(
-                    filename=self.args.filename
-                )
-            )
-            self.host.quit()
-
-
-class Param(base.CommandBase):
-    subcommands = (Get, Set, SaveTemplate, LoadTemplate)
-
-    def __init__(self, host):
-        super(Param, self).__init__(
-            host, "param", use_profile=False, help=_("Save/load parameters template")
-        )
--- a/sat_frontends/jp/cmd_ping.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,46 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-
-__commands__ = ["Ping"]
-
-
-class Ping(base.CommandBase):
-    def __init__(self, host):
-        super(Ping, self).__init__(host, "ping", help=_("ping XMPP entity"))
-
-    def add_parser_options(self):
-        self.parser.add_argument("jid", help=_("jid to ping"))
-        self.parser.add_argument(
-            "-d", "--delay-only", action="store_true", help=_("output delay only (in s)")
-        )
-
-    async def start(self):
-        try:
-            pong_time = await self.host.bridge.ping(self.args.jid, self.profile)
-        except Exception as e:
-            self.disp(msg=_("can't do the ping: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            msg = pong_time if self.args.delay_only else f"PONG ({pong_time} s)"
-            self.disp(msg)
-            self.host.quit()
--- a/sat_frontends/jp/cmd_pipe.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,163 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-import asyncio
-import errno
-from functools import partial
-import socket
-import sys
-
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-from sat_frontends.jp import base
-from sat_frontends.jp import xmlui_manager
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.tools import jid
-
-__commands__ = ["Pipe"]
-
-START_PORT = 9999
-
-
-class PipeOut(base.CommandBase):
-    def __init__(self, host):
-        super(PipeOut, self).__init__(host, "out", help=_("send a pipe a stream"))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jid", help=_("the destination jid")
-        )
-
-    async def start(self):
-        """ Create named pipe, and send stdin to it """
-        try:
-            port = await self.host.bridge.stream_out(
-                await self.host.get_full_jid(self.args.jid),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't start stream: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            # FIXME: we use temporarily blocking code here, as it simplify
-            #        asyncio port: "loop.connect_read_pipe(lambda: reader_protocol,
-            #        sys.stdin.buffer)" doesn't work properly when a file is piped in
-            #        (we get a "ValueError: Pipe transport is for pipes/sockets only.")
-            #        while it's working well for simple text sending.
-
-            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-            s.connect(("127.0.0.1", int(port)))
-
-            while True:
-                buf = sys.stdin.buffer.read(4096)
-                if not buf:
-                    break
-                try:
-                    s.sendall(buf)
-                except socket.error as e:
-                    if e.errno == errno.EPIPE:
-                        sys.stderr.write(f"e\n")
-                        self.host.quit(1)
-                    else:
-                        raise e
-            self.host.quit()
-
-
-async def handle_stream_in(reader, writer, host):
-    """Write all received data to stdout"""
-    while True:
-        data = await reader.read(4096)
-        if not data:
-            break
-        sys.stdout.buffer.write(data)
-        try:
-            sys.stdout.flush()
-        except IOError as e:
-            sys.stderr.write(f"{e}\n")
-            break
-    host.quit_from_signal()
-
-
-class PipeIn(base.CommandAnswering):
-    def __init__(self, host):
-        super(PipeIn, self).__init__(host, "in", help=_("receive a pipe stream"))
-        self.action_callbacks = {"STREAM": self.on_stream_action}
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "jids",
-            nargs="*",
-            help=_('Jids accepted (none means "accept everything")'),
-        )
-
-    def get_xmlui_id(self, action_data):
-        try:
-            xml_ui = action_data["xmlui"]
-        except KeyError:
-            self.disp(_("Action has no XMLUI"), 1)
-        else:
-            ui = xmlui_manager.create(self.host, xml_ui)
-            if not ui.submit_id:
-                self.disp(_("Invalid XMLUI received"), error=True)
-                self.quit_from_signal(C.EXIT_INTERNAL_ERROR)
-            return ui.submit_id
-
-    async def on_stream_action(self, action_data, action_id, security_limit, profile):
-        xmlui_id = self.get_xmlui_id(action_data)
-        if xmlui_id is None:
-            self.host.quit_from_signal(C.EXIT_ERROR)
-        try:
-            from_jid = jid.JID(action_data["from_jid"])
-        except KeyError:
-            self.disp(_("Ignoring action without from_jid data"), error=True)
-            return
-
-        if not self.bare_jids or from_jid.bare in self.bare_jids:
-            host, port = "localhost", START_PORT
-            while True:
-                try:
-                    server = await asyncio.start_server(
-                        partial(handle_stream_in, host=self.host), host, port)
-                except socket.error as e:
-                    if e.errno == errno.EADDRINUSE:
-                        port += 1
-                    else:
-                        raise e
-                else:
-                    break
-            xmlui_data = {"answer": C.BOOL_TRUE, "port": str(port)}
-            await self.host.bridge.action_launch(
-                xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
-            )
-            async with server:
-                await server.serve_forever()
-            self.host.quit_from_signal()
-
-    async def start(self):
-        self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids]
-        await self.start_answering()
-
-
-class Pipe(base.CommandBase):
-    subcommands = (PipeOut, PipeIn)
-
-    def __init__(self, host):
-        super(Pipe, self).__init__(
-            host, "pipe", use_profile=False, help=_("stream piping through XMPP")
-        )
--- a/sat_frontends/jp/cmd_profile.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,280 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-"""This module permits to manage profiles. It can list, create, delete
-and retrieve information about a profile."""
-
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.core.log import getLogger
-from libervia.backend.core.i18n import _
-from sat_frontends.jp import base
-
-log = getLogger(__name__)
-
-
-__commands__ = ["Profile"]
-
-PROFILE_HELP = _('The name of the profile')
-
-
-class ProfileConnect(base.CommandBase):
-    """Dummy command to use profile_session parent, i.e. to be able to connect without doing anything else"""
-
-    def __init__(self, host):
-        # it's weird to have a command named "connect" with need_connect=False, but it can be handy to be able
-        # to launch just the session, so some paradoxes don't hurt
-        super(ProfileConnect, self).__init__(host, 'connect', need_connect=False, help=('connect a profile'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        # connection is already managed by profile common commands
-        # so we just need to check arguments and quit
-        if not self.args.connect and not self.args.start_session:
-            self.parser.error(_("You need to use either --connect or --start-session"))
-        self.host.quit()
-
-class ProfileDisconnect(base.CommandBase):
-
-    def __init__(self, host):
-        super(ProfileDisconnect, self).__init__(host, 'disconnect', need_connect=False, help=('disconnect a profile'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            await self.host.bridge.disconnect(self.args.profile)
-        except Exception as e:
-            self.disp(f"can't disconnect profile: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class ProfileCreate(base.CommandBase):
-    def __init__(self, host):
-        super(ProfileCreate, self).__init__(
-            host, 'create', use_profile=False, help=('create a new profile'))
-
-    def add_parser_options(self):
-        self.parser.add_argument('profile', type=str, help=_('the name of the profile'))
-        self.parser.add_argument(
-            '-p', '--password', type=str, default='',
-            help=_('the password of the profile'))
-        self.parser.add_argument(
-            '-j', '--jid', type=str, help=_('the jid of the profile'))
-        self.parser.add_argument(
-            '-x', '--xmpp-password', type=str,
-            help=_(
-                'the password of the XMPP account (use profile password if not specified)'
-            ),
-            metavar='PASSWORD')
-        self.parser.add_argument(
-            '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?',
-            const=C.BOOL_TRUE,
-            help=_('connect this profile automatically when backend starts')
-        )
-        self.parser.add_argument(
-            '-C', '--component', default='',
-            help=_('set to component import name (entry point) if this is a component'))
-
-    async def start(self):
-        """Create a new profile"""
-        if self.args.profile in await self.host.bridge.profiles_list_get():
-            self.disp(f"Profile {self.args.profile} already exists.", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERROR)
-        try:
-            await self.host.bridge.profile_create(
-                self.args.profile, self.args.password, self.args.component)
-        except Exception as e:
-            self.disp(f"can't create profile: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        try:
-            await self.host.bridge.profile_start_session(
-                self.args.password, self.args.profile)
-        except Exception as e:
-            self.disp(f"can't start profile session: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if self.args.jid:
-            await self.host.bridge.param_set(
-                "JabberID", self.args.jid, "Connection", profile_key=self.args.profile)
-        xmpp_pwd = self.args.password or self.args.xmpp_password
-        if xmpp_pwd:
-            await self.host.bridge.param_set(
-                "Password", xmpp_pwd, "Connection", profile_key=self.args.profile)
-
-        if self.args.autoconnect is not None:
-            await self.host.bridge.param_set(
-                "autoconnect_backend", self.args.autoconnect, "Connection",
-                profile_key=self.args.profile)
-
-        self.disp(f'profile {self.args.profile} created successfully', 1)
-        self.host.quit()
-
-
-class ProfileDefault(base.CommandBase):
-    def __init__(self, host):
-        super(ProfileDefault, self).__init__(
-            host, 'default', use_profile=False, help=('print default profile'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        print(await self.host.bridge.profile_name_get('@DEFAULT@'))
-        self.host.quit()
-
-
-class ProfileDelete(base.CommandBase):
-    def __init__(self, host):
-        super(ProfileDelete, self).__init__(host, 'delete', use_profile=False, help=('delete a profile'))
-
-    def add_parser_options(self):
-        self.parser.add_argument('profile', type=str, help=PROFILE_HELP)
-        self.parser.add_argument('-f', '--force', action='store_true', help=_('delete profile without confirmation'))
-
-    async def start(self):
-        if self.args.profile not in await self.host.bridge.profiles_list_get():
-            log.error(f"Profile {self.args.profile} doesn't exist.")
-            self.host.quit(C.EXIT_NOT_FOUND)
-        if not self.args.force:
-            message = f"Are you sure to delete profile [{self.args.profile}] ?"
-            cancel_message = "Profile deletion cancelled"
-            await self.host.confirm_or_quit(message, cancel_message)
-
-        await self.host.bridge.profile_delete_async(self.args.profile)
-        self.host.quit()
-
-
-class ProfileInfo(base.CommandBase):
-
-    def __init__(self, host):
-        super(ProfileInfo, self).__init__(
-            host, 'info', need_connect=False, use_output=C.OUTPUT_DICT,
-            help=_('get information about a profile'))
-        self.to_show = [(_("jid"), "Connection", "JabberID"),]
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            '--show-password', action='store_true',
-            help=_('show the XMPP password IN CLEAR TEXT'))
-
-    async def start(self):
-        if self.args.show_password:
-            self.to_show.append((_("XMPP password"), "Connection", "Password"))
-        self.to_show.append((_("autoconnect (backend)"), "Connection",
-                                "autoconnect_backend"))
-        data = {}
-        for label, category, name in self.to_show:
-            try:
-                value = await self.host.bridge.param_get_a_async(
-                    name, category, profile_key=self.host.profile)
-            except Exception as e:
-                self.disp(f"can't get {name}/{category} param: {e}", error=True)
-            else:
-                data[label] = value
-
-        await self.output(data)
-        self.host.quit()
-
-
-class ProfileList(base.CommandBase):
-    def __init__(self, host):
-        super(ProfileList, self).__init__(
-            host, 'list', use_profile=False, use_output='list', help=('list profiles'))
-
-    def add_parser_options(self):
-        group = self.parser.add_mutually_exclusive_group()
-        group.add_argument(
-            '-c', '--clients', action='store_true', help=_('get clients profiles only'))
-        group.add_argument(
-            '-C', '--components', action='store_true',
-            help=('get components profiles only'))
-
-    async def start(self):
-        if self.args.clients:
-            clients, components = True, False
-        elif self.args.components:
-            clients, components = False, True
-        else:
-            clients, components = True, True
-        await self.output(await self.host.bridge.profiles_list_get(clients, components))
-        self.host.quit()
-
-
-class ProfileModify(base.CommandBase):
-
-    def __init__(self, host):
-        super(ProfileModify, self).__init__(
-            host, 'modify', need_connect=False, help=_('modify an existing profile'))
-
-    def add_parser_options(self):
-        profile_pwd_group = self.parser.add_mutually_exclusive_group()
-        profile_pwd_group.add_argument(
-            '-w', '--password', help=_('change the password of the profile'))
-        profile_pwd_group.add_argument(
-            '--disable-password', action='store_true',
-            help=_('disable profile password (dangerous!)'))
-        self.parser.add_argument('-j', '--jid', help=_('the jid of the profile'))
-        self.parser.add_argument(
-            '-x', '--xmpp-password', help=_('change the password of the XMPP account'),
-            metavar='PASSWORD')
-        self.parser.add_argument(
-            '-D', '--default', action='store_true', help=_('set as default profile'))
-        self.parser.add_argument(
-            '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?',
-            const=C.BOOL_TRUE,
-            help=_('connect this profile automatically when backend starts')
-        )
-
-    async def start(self):
-        if self.args.disable_password:
-            self.args.password = ''
-        if self.args.password is not None:
-            await self.host.bridge.param_set(
-                "Password", self.args.password, "General", profile_key=self.host.profile)
-        if self.args.jid is not None:
-            await self.host.bridge.param_set(
-                "JabberID", self.args.jid, "Connection", profile_key=self.host.profile)
-        if self.args.xmpp_password is not None:
-            await self.host.bridge.param_set(
-                "Password", self.args.xmpp_password, "Connection",
-                profile_key=self.host.profile)
-        if self.args.default:
-            await self.host.bridge.profile_set_default(self.host.profile)
-        if self.args.autoconnect is not None:
-            await self.host.bridge.param_set(
-                "autoconnect_backend", self.args.autoconnect, "Connection",
-                profile_key=self.host.profile)
-
-        self.host.quit()
-
-
-class Profile(base.CommandBase):
-    subcommands = (
-        ProfileConnect, ProfileDisconnect, ProfileCreate, ProfileDefault, ProfileDelete,
-        ProfileInfo, ProfileList, ProfileModify)
-
-    def __init__(self, host):
-        super(Profile, self).__init__(
-            host, 'profile', use_profile=False, help=_('profile commands'))
--- a/sat_frontends/jp/cmd_pubsub.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3030 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-
-import argparse
-import os.path
-import re
-import sys
-import subprocess
-import asyncio
-import json
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.jp import common
-from sat_frontends.jp import arg_tools
-from sat_frontends.jp import xml_tools
-from functools import partial
-from libervia.backend.tools.common import data_format
-from libervia.backend.tools.common import uri
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import date_utils
-from sat_frontends.tools import jid, strings
-from sat_frontends.bridge.bridge_frontend import BridgeException
-
-__commands__ = ["Pubsub"]
-
-PUBSUB_TMP_DIR = "pubsub"
-PUBSUB_SCHEMA_TMP_DIR = PUBSUB_TMP_DIR + "_schema"
-ALLOWED_SUBSCRIPTIONS_OWNER = ("subscribed", "pending", "none")
-
-# TODO: need to split this class in several modules, plugin should handle subcommands
-
-
-class NodeInfo(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "info",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("retrieve node configuration"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            action="append",
-            dest="keys",
-            help=_("data key to filter"),
-        )
-
-    def remove_prefix(self, key):
-        return key[7:] if key.startswith("pubsub#") else key
-
-    def filter_key(self, key):
-        return any((key == k or key == "pubsub#" + k) for k in self.args.keys)
-
-    async def start(self):
-        try:
-            config_dict = await self.host.bridge.ps_node_configuration_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found":
-                self.disp(
-                    f"The node {self.args.node} doesn't exist on {self.args.service}",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(f"can't get node configuration: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            key_filter = (lambda k: True) if not self.args.keys else self.filter_key
-            config_dict = {
-                self.remove_prefix(k): v for k, v in config_dict.items() if key_filter(k)
-            }
-            await self.output(config_dict)
-            self.host.quit()
-
-
-class NodeCreate(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "create",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("create a node"),
-        )
-
-    @staticmethod
-    def add_node_config_options(parser):
-        parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            default=[],
-            metavar=("KEY", "VALUE"),
-            help=_("configuration field to set"),
-        )
-        parser.add_argument(
-            "-F",
-            "--full-prefix",
-            action="store_true",
-            help=_('don\'t prepend "pubsub#" prefix to field names'),
-        )
-
-    def add_parser_options(self):
-        self.add_node_config_options(self.parser)
-
-    @staticmethod
-    def get_config_options(args):
-        if not args.full_prefix:
-            return {"pubsub#" + k: v for k, v in args.fields}
-        else:
-            return dict(args.fields)
-
-    async def start(self):
-        options = self.get_config_options(self.args)
-        try:
-            node_id = await self.host.bridge.ps_node_create(
-                self.args.service,
-                self.args.node,
-                options,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't create node: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if self.host.verbosity:
-                announce = _("node created successfully: ")
-            else:
-                announce = ""
-            self.disp(announce + node_id)
-            self.host.quit()
-
-
-class NodePurge(base.CommandBase):
-    def __init__(self, host):
-        super(NodePurge, self).__init__(
-            host,
-            "purge",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("purge a node (i.e. remove all items from it)"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("purge node without confirmation"),
-        )
-
-    async def start(self):
-        if not self.args.force:
-            if not self.args.service:
-                message = _(
-                    "Are you sure to purge PEP node [{node}]? This will "
-                    "delete ALL items from it!"
-                ).format(node=self.args.node)
-            else:
-                message = _(
-                    "Are you sure to delete node [{node}] on service "
-                    "[{service}]? This will delete ALL items from it!"
-                ).format(node=self.args.node, service=self.args.service)
-            await self.host.confirm_or_quit(message, _("node purge cancelled"))
-
-        try:
-            await self.host.bridge.ps_node_purge(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(msg=_("can't purge node: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("node [{node}] purged successfully").format(node=self.args.node))
-            self.host.quit()
-
-
-class NodeDelete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("delete a node"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=_("delete node without confirmation"),
-        )
-
-    async def start(self):
-        if not self.args.force:
-            if not self.args.service:
-                message = _("Are you sure to delete PEP node [{node}] ?").format(
-                    node=self.args.node
-                )
-            else:
-                message = _(
-                    "Are you sure to delete node [{node}] on " "service [{service}]?"
-                ).format(node=self.args.node, service=self.args.service)
-            await self.host.confirm_or_quit(message, _("node deletion cancelled"))
-
-        try:
-            await self.host.bridge.ps_node_delete(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't delete node: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("node [{node}] deleted successfully").format(node=self.args.node))
-            self.host.quit()
-
-
-class NodeSet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("set node configuration"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            required=True,
-            metavar=("KEY", "VALUE"),
-            help=_("configuration field to set (required)"),
-        )
-        self.parser.add_argument(
-            "-F",
-            "--full-prefix",
-            action="store_true",
-            help=_('don\'t prepend "pubsub#" prefix to field names'),
-        )
-
-    def get_key_name(self, k):
-        if self.args.full_prefix or k.startswith("pubsub#"):
-            return k
-        else:
-            return "pubsub#" + k
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_node_configuration_set(
-                self.args.service,
-                self.args.node,
-                {self.get_key_name(k): v for k, v in self.args.fields},
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set node configuration: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("node configuration successful"), 1)
-            self.host.quit()
-
-
-class NodeImport(base.CommandBase):
-    def __init__(self, host):
-        super(NodeImport, self).__init__(
-            host,
-            "import",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("import raw XML to a node"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--admin",
-            action="store_true",
-            help=_("do a pubsub admin request, needed to change publisher"),
-        )
-        self.parser.add_argument(
-            "import_file",
-            type=argparse.FileType(),
-            help=_(
-                "path to the XML file with data to import. The file must contain "
-                "whole XML of each item to import."
-            ),
-        )
-
-    async def start(self):
-        try:
-            element, etree = xml_tools.etree_parse(
-                self, self.args.import_file, reraise=True
-            )
-        except Exception as e:
-            from lxml.etree import XMLSyntaxError
-
-            if isinstance(e, XMLSyntaxError) and e.code == 5:
-                # we have extra content, this probaby means that item are not wrapped
-                # so we wrap them here and try again
-                self.args.import_file.seek(0)
-                xml_buf = "<import>" + self.args.import_file.read() + "</import>"
-                element, etree = xml_tools.etree_parse(self, xml_buf)
-
-                # we reverse element as we expect to have most recently published element first
-                # TODO: make this more explicit and add an option
-        element[:] = reversed(element)
-
-        if not all([i.tag == "{http://jabber.org/protocol/pubsub}item" for i in element]):
-            self.disp(
-                _("You are not using list of pubsub items, we can't import this file"),
-                error=True,
-            )
-            self.host.quit(C.EXIT_DATA_ERROR)
-            return
-
-        items = [etree.tostring(i, encoding="unicode") for i in element]
-        if self.args.admin:
-            method = self.host.bridge.ps_admin_items_send
-        else:
-            self.disp(
-                _(
-                    "Items are imported without using admin mode, publisher can't "
-                    "be changed"
-                )
-            )
-            method = self.host.bridge.ps_items_send
-
-        try:
-            items_ids = await method(
-                self.args.service,
-                self.args.node,
-                items,
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't send items: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if items_ids:
-                self.disp(
-                    _("items published with id(s) {items_ids}").format(
-                        items_ids=", ".join(items_ids)
-                    )
-                )
-            else:
-                self.disp(_("items published"))
-            self.host.quit()
-
-
-class NodeAffiliationsGet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("retrieve node affiliations (for node owner)"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            affiliations = await self.host.bridge.ps_node_affiliations_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get node affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(affiliations)
-            self.host.quit()
-
-
-class NodeAffiliationsSet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("set affiliations (for node owner)"),
-        )
-
-    def add_parser_options(self):
-        # XXX: we use optional argument syntax for a required one because list of list of 2 elements
-        #      (used to construct dicts) don't work with positional arguments
-        self.parser.add_argument(
-            "-a",
-            "--affiliation",
-            dest="affiliations",
-            metavar=("JID", "AFFILIATION"),
-            required=True,
-            action="append",
-            nargs=2,
-            help=_("entity/affiliation couple(s)"),
-        )
-
-    async def start(self):
-        affiliations = dict(self.args.affiliations)
-        try:
-            await self.host.bridge.ps_node_affiliations_set(
-                self.args.service,
-                self.args.node,
-                affiliations,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set node affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("affiliations have been set"), 1)
-            self.host.quit()
-
-
-class NodeAffiliations(base.CommandBase):
-    subcommands = (NodeAffiliationsGet, NodeAffiliationsSet)
-
-    def __init__(self, host):
-        super(NodeAffiliations, self).__init__(
-            host,
-            "affiliations",
-            use_profile=False,
-            help=_("set or retrieve node affiliations"),
-        )
-
-
-class NodeSubscriptionsGet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("retrieve node subscriptions (for node owner)"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--public",
-            action="store_true",
-            help=_("get public subscriptions"),
-        )
-
-    async def start(self):
-        if self.args.public:
-            method = self.host.bridge.ps_public_node_subscriptions_get
-        else:
-            method = self.host.bridge.ps_node_subscriptions_get
-        try:
-            subscriptions = await method(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get node subscriptions: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(subscriptions)
-            self.host.quit()
-
-
-class StoreSubscriptionAction(argparse.Action):
-    """Action which handle subscription parameter for owner
-
-    list is given by pairs: jid and subscription state
-    if subscription state is not specified, it default to "subscribed"
-    """
-
-    def __call__(self, parser, namespace, values, option_string):
-        dest_dict = getattr(namespace, self.dest)
-        while values:
-            jid_s = values.pop(0)
-            try:
-                subscription = values.pop(0)
-            except IndexError:
-                subscription = "subscribed"
-            if subscription not in ALLOWED_SUBSCRIPTIONS_OWNER:
-                parser.error(
-                    _("subscription must be one of {}").format(
-                        ", ".join(ALLOWED_SUBSCRIPTIONS_OWNER)
-                    )
-                )
-            dest_dict[jid_s] = subscription
-
-
-class NodeSubscriptionsSet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("set/modify subscriptions (for node owner)"),
-        )
-
-    def add_parser_options(self):
-        # XXX: we use optional argument syntax for a required one because list of list of 2 elements
-        #      (uses to construct dicts) don't work with positional arguments
-        self.parser.add_argument(
-            "-S",
-            "--subscription",
-            dest="subscriptions",
-            default={},
-            nargs="+",
-            metavar=("JID [SUSBSCRIPTION]"),
-            required=True,
-            action=StoreSubscriptionAction,
-            help=_("entity/subscription couple(s)"),
-        )
-
-    async def start(self):
-        try:
-            self.host.bridge.ps_node_subscriptions_set(
-                self.args.service,
-                self.args.node,
-                self.args.subscriptions,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set node subscriptions: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("subscriptions have been set"), 1)
-            self.host.quit()
-
-
-class NodeSubscriptions(base.CommandBase):
-    subcommands = (NodeSubscriptionsGet, NodeSubscriptionsSet)
-
-    def __init__(self, host):
-        super(NodeSubscriptions, self).__init__(
-            host,
-            "subscriptions",
-            use_profile=False,
-            help=_("get or modify node subscriptions"),
-        )
-
-
-class NodeSchemaSet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("set/replace a schema"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("schema", help=_("schema to set (must be XML)"))
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_schema_set(
-                self.args.service,
-                self.args.node,
-                self.args.schema,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set schema: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("schema has been set"), 1)
-            self.host.quit()
-
-
-class NodeSchemaEdit(base.CommandBase, common.BaseEdit):
-    use_items = False
-
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "edit",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_draft=True,
-            use_verbose=True,
-            help=_("edit a schema"),
-        )
-        common.BaseEdit.__init__(self, self.host, PUBSUB_SCHEMA_TMP_DIR)
-
-    def add_parser_options(self):
-        pass
-
-    async def publish(self, schema):
-        try:
-            await self.host.bridge.ps_schema_set(
-                self.args.service,
-                self.args.node,
-                schema,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't set schema: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("schema has been set"), 1)
-            self.host.quit()
-
-    async def ps_schema_get_cb(self, schema):
-        try:
-            from lxml import etree
-        except ImportError:
-            self.disp(
-                "lxml module must be installed to use edit, please install it "
-                'with "pip install lxml"',
-                error=True,
-            )
-            self.host.quit(1)
-        content_file_obj, content_file_path = self.get_tmp_file()
-        schema = schema.strip()
-        if schema:
-            parser = etree.XMLParser(remove_blank_text=True)
-            schema_elt = etree.fromstring(schema, parser)
-            content_file_obj.write(
-                etree.tostring(schema_elt, encoding="utf-8", pretty_print=True)
-            )
-            content_file_obj.seek(0)
-        await self.run_editor(
-            "pubsub_schema_editor_args", content_file_path, content_file_obj
-        )
-
-    async def start(self):
-        try:
-            schema = await self.host.bridge.ps_schema_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found" or e.classname == "NotFound":
-                schema = ""
-            else:
-                self.disp(f"can't edit schema: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        await self.ps_schema_get_cb(schema)
-
-
-class NodeSchemaGet(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_XML,
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("get schema"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            schema = await self.host.bridge.ps_schema_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found" or e.classname == "NotFound":
-                schema = None
-            else:
-                self.disp(f"can't get schema: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        if schema:
-            await self.output(schema)
-            self.host.quit()
-        else:
-            self.disp(_("no schema found"), 1)
-            self.host.quit(C.EXIT_NOT_FOUND)
-
-
-class NodeSchema(base.CommandBase):
-    subcommands = (NodeSchemaSet, NodeSchemaEdit, NodeSchemaGet)
-
-    def __init__(self, host):
-        super(NodeSchema, self).__init__(
-            host, "schema", use_profile=False, help=_("data schema manipulation")
-        )
-
-
-class Node(base.CommandBase):
-    subcommands = (
-        NodeInfo,
-        NodeCreate,
-        NodePurge,
-        NodeDelete,
-        NodeSet,
-        NodeImport,
-        NodeAffiliations,
-        NodeSubscriptions,
-        NodeSchema,
-    )
-
-    def __init__(self, host):
-        super(Node, self).__init__(
-            host, "node", use_profile=False, help=_("node handling")
-        )
-
-
-class CacheGet(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "get",
-            use_output=C.OUTPUT_LIST_XML,
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.MULTI_ITEMS, C.CACHE},
-            help=_("get pubsub item(s) from cache"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-S",
-            "--sub-id",
-            default="",
-            help=_("subscription id"),
-        )
-
-    async def start(self):
-        try:
-            ps_result = data_format.deserialise(
-                await self.host.bridge.ps_cache_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    self.args.sub_id,
-                    self.get_pubsub_extra(),
-                    self.profile,
-                )
-            )
-        except BridgeException as e:
-            if e.classname == "NotFound":
-                self.disp(
-                    f"The node {self.args.node} from {self.args.service} is not in cache "
-                    f"for {self.profile}",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(f"can't get pubsub items from cache: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            await self.output(ps_result["items"])
-            self.host.quit(C.EXIT_OK)
-
-
-class CacheSync(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "sync",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("(re)synchronise a pubsub node"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_cache_sync(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found" or e.classname == "NotFound":
-                self.disp(
-                    f"The node {self.args.node} doesn't exist on {self.args.service}",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(f"can't synchronise pubsub node: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class CachePurge(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "purge",
-            use_profile=False,
-            help=_("purge (delete) items from cache"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-s", "--service", action="append", metavar="JID", dest="services",
-            help="purge items only for these services. If not specified, items from ALL "
-            "services will be purged. May be used several times."
-        )
-        self.parser.add_argument(
-            "-n", "--node", action="append", dest="nodes",
-            help="purge items only for these nodes. If not specified, items from ALL "
-            "nodes will be purged. May be used several times."
-        )
-        self.parser.add_argument(
-            "-p", "--profile", action="append", dest="profiles",
-            help="purge items only for these profiles. If not specified, items from ALL "
-            "profiles will be purged. May be used several times."
-        )
-        self.parser.add_argument(
-            "-b", "--updated-before", type=base.date_decoder, metavar="TIME_PATTERN",
-            help="purge items which have been last updated before given time."
-        )
-        self.parser.add_argument(
-            "-C", "--created-before", type=base.date_decoder, metavar="TIME_PATTERN",
-            help="purge items which have been last created before given time."
-        )
-        self.parser.add_argument(
-            "-t", "--type", action="append", dest="types",
-            help="purge items flagged with TYPE. May be used several times."
-        )
-        self.parser.add_argument(
-            "-S", "--subtype", action="append", dest="subtypes",
-            help="purge items flagged with SUBTYPE. May be used several times."
-        )
-        self.parser.add_argument(
-            "-f", "--force", action="store_true",
-            help=_("purge items without confirmation")
-        )
-
-    async def start(self):
-        if not self.args.force:
-            await self.host.confirm_or_quit(
-                _(
-                    "Are you sure to purge items from cache? You'll have to bypass cache "
-                    "or resynchronise nodes to access deleted items again."
-                ),
-                _("Items purgins has been cancelled.")
-            )
-        purge_data = {}
-        for key in (
-                "services", "nodes", "profiles", "updated_before", "created_before",
-                "types", "subtypes"
-        ):
-            value = getattr(self.args, key)
-            if value is not None:
-                purge_data[key] = value
-        try:
-            await self.host.bridge.ps_cache_purge(
-                data_format.serialise(
-                    purge_data
-                )
-            )
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class CacheReset(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "reset",
-            use_profile=False,
-            help=_("remove everything from cache"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--force", action="store_true",
-            help=_("reset cache without confirmation")
-        )
-
-    async def start(self):
-        if not self.args.force:
-            await self.host.confirm_or_quit(
-                _(
-                    "Are you sure to reset cache? All nodes and items will be removed "
-                    "from it, then it will be progressively refilled as if it were new. "
-                    "This may be resources intensive."
-                ),
-                _("Pubsub cache reset has been cancelled.")
-            )
-        try:
-            await self.host.bridge.ps_cache_reset()
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            self.host.quit(C.EXIT_OK)
-
-
-class CacheSearch(base.CommandBase):
-    def __init__(self, host):
-        extra_outputs = {
-            "default": self.default_output,
-            "xml": self.xml_output,
-            "xml-raw": self.xml_raw_output,
-        }
-        super().__init__(
-            host,
-            "search",
-            use_profile=False,
-            use_output=C.OUTPUT_LIST_DICT,
-            extra_outputs=extra_outputs,
-            help=_("search for pubsub items in cache"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--fts", help=_("Full-Text Search query"), metavar="FTS_QUERY"
-        )
-        self.parser.add_argument(
-            "-p", "--profile", action="append", dest="profiles", metavar="PROFILE",
-            help="search items only from these profiles. May be used several times."
-        )
-        self.parser.add_argument(
-            "-s", "--service", action="append", dest="services", metavar="SERVICE",
-            help="items must be from specified service. May be used several times."
-        )
-        self.parser.add_argument(
-            "-n", "--node", action="append", dest="nodes", metavar="NODE",
-            help="items must be in the specified node. May be used several times."
-        )
-        self.parser.add_argument(
-            "-t", "--type", action="append", dest="types", metavar="TYPE",
-            help="items must be of specified type. May be used several times."
-        )
-        self.parser.add_argument(
-            "-S", "--subtype", action="append", dest="subtypes", metavar="SUBTYPE",
-            help="items must be of specified subtype. May be used several times."
-        )
-        self.parser.add_argument(
-            "-P", "--payload", action="store_true", help=_("include item XML payload")
-        )
-        self.parser.add_argument(
-            "-o", "--order-by", action="append", nargs="+",
-            metavar=("ORDER", "[FIELD] [DIRECTION]"),
-            help=_("how items must be ordered. May be used several times.")
-        )
-        self.parser.add_argument(
-            "-l", "--limit", type=int, help=_("maximum number of items to return")
-        )
-        self.parser.add_argument(
-            "-i", "--index", type=int, help=_("return results starting from this index")
-        )
-        self.parser.add_argument(
-            "-F",
-            "--field",
-            action="append",
-            nargs=3,
-            dest="fields",
-            default=[],
-            metavar=("PATH", "OPERATOR", "VALUE"),
-            help=_("parsed data field filter. May be used several times."),
-        )
-        self.parser.add_argument(
-            "-k",
-            "--key",
-            action="append",
-            dest="keys",
-            metavar="KEY",
-            help=_(
-                "data key(s) to display. May be used several times. DEFAULT: show all "
-                "keys"
-            ),
-        )
-
-    async def start(self):
-        query = {}
-        for arg in ("fts", "profiles", "services", "nodes", "types", "subtypes"):
-            value = getattr(self.args, arg)
-            if value:
-                if arg in ("types", "subtypes"):
-                    # empty string is used to find items without type and/or subtype
-                    value = [v or None for v in value]
-                query[arg] = value
-        for arg in ("limit", "index"):
-            value = getattr(self.args, arg)
-            if value is not None:
-                query[arg] = value
-        if self.args.order_by is not None:
-            for order_data in self.args.order_by:
-                order, *args = order_data
-                if order == "field":
-                    if not args:
-                        self.parser.error(_("field data must be specified in --order-by"))
-                    elif len(args) == 1:
-                        path = args[0]
-                        direction = "asc"
-                    elif len(args) == 2:
-                        path, direction = args
-                    else:
-                        self.parser.error(_(
-                            "You can't specify more that 2 arguments for a field in "
-                            "--order-by"
-                        ))
-                    try:
-                        path = json.loads(path)
-                    except json.JSONDecodeError:
-                        pass
-                    order_query = {
-                        "path": path,
-                    }
-                else:
-                    order_query = {
-                        "order": order
-                    }
-                    if not args:
-                        direction = "asc"
-                    elif len(args) == 1:
-                        direction = args[0]
-                    else:
-                        self.parser.error(_(
-                            "there are too many arguments in --order-by option"
-                        ))
-                if direction.lower() not in ("asc", "desc"):
-                    self.parser.error(_("invalid --order-by direction: {direction!r}"))
-                order_query["direction"] = direction
-                query.setdefault("order-by", []).append(order_query)
-
-        if self.args.fields:
-            parsed = []
-            for field in self.args.fields:
-                path, operator, value = field
-                try:
-                    path = json.loads(path)
-                except json.JSONDecodeError:
-                    # this is not a JSON encoded value, we keep it as a string
-                    pass
-
-                if not isinstance(path, list):
-                    path = [path]
-
-                # handling of TP(<time pattern>)
-                if operator in (">", "gt", "<", "le", "between"):
-                    def datetime_sub(match):
-                        return str(date_utils.date_parse_ext(
-                            match.group(1), default_tz=date_utils.TZ_LOCAL
-                        ))
-                    value = re.sub(r"\bTP\(([^)]+)\)", datetime_sub, value)
-
-                try:
-                    value = json.loads(value)
-                except json.JSONDecodeError:
-                    # not JSON, as above we keep it as string
-                    pass
-
-                if operator in ("overlap", "ioverlap", "disjoint", "idisjoint"):
-                    if not isinstance(value, list):
-                        value = [value]
-
-                parsed.append({
-                    "path": path,
-                    "op": operator,
-                    "value": value
-                })
-
-            query["parsed"] = parsed
-
-        if self.args.payload or "xml" in self.args.output:
-            query["with_payload"] = True
-            if self.args.keys:
-                self.args.keys.append("item_payload")
-        try:
-            found_items = data_format.deserialise(
-                await self.host.bridge.ps_cache_search(
-                    data_format.serialise(query)
-                ),
-                type_check=list,
-            )
-        except BridgeException as e:
-            self.disp(f"can't search for pubsub items in cache: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            if self.args.keys:
-                found_items = [
-                    {k: v for k,v in item.items() if k in self.args.keys}
-                    for item in found_items
-                ]
-            await self.output(found_items)
-            self.host.quit(C.EXIT_OK)
-
-    def default_output(self, found_items):
-        for item in found_items:
-            for field in ("created", "published", "updated"):
-                try:
-                    timestamp = item[field]
-                except KeyError:
-                    pass
-                else:
-                    try:
-                        item[field] = common.format_time(timestamp)
-                    except ValueError:
-                        pass
-        self.host._outputs[C.OUTPUT_LIST_DICT]["simple"]["callback"](found_items)
-
-    def xml_output(self, found_items):
-        """Output prettified item payload"""
-        cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML]["callback"]
-        for item in found_items:
-            cb(item["item_payload"])
-
-    def xml_raw_output(self, found_items):
-        """Output item payload without prettifying"""
-        cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML_RAW]["callback"]
-        for item in found_items:
-            cb(item["item_payload"])
-
-
-class Cache(base.CommandBase):
-    subcommands = (
-        CacheGet,
-        CacheSync,
-        CachePurge,
-        CacheReset,
-        CacheSearch,
-    )
-
-    def __init__(self, host):
-        super(Cache, self).__init__(
-            host, "cache", use_profile=False, help=_("pubsub cache handling")
-        )
-
-
-class Set(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "set",
-            use_pubsub=True,
-            use_quiet=True,
-            pubsub_flags={C.NODE},
-            help=_("publish a new item or update an existing one"),
-        )
-
-    def add_parser_options(self):
-        NodeCreate.add_node_config_options(self.parser)
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("end-to-end encrypt the blog item")
-        )
-        self.parser.add_argument(
-            "--encrypt-for",
-            metavar="JID",
-            action="append",
-            help=_("encrypt a single item for")
-        )
-        self.parser.add_argument(
-            "-X",
-            "--sign",
-            action="store_true",
-            help=_("cryptographically sign the blog post")
-        )
-        self.parser.add_argument(
-            "item",
-            nargs="?",
-            default="",
-            help=_("id, URL of the item to update, keyword, or nothing for new item"),
-        )
-
-    async def start(self):
-        element, etree = xml_tools.etree_parse(self, sys.stdin)
-        element = xml_tools.get_payload(self, element)
-        payload = etree.tostring(element, encoding="unicode")
-        extra = {}
-        if self.args.encrypt:
-            extra["encrypted"] = True
-        if self.args.encrypt_for:
-            extra["encrypted_for"] = {"targets": self.args.encrypt_for}
-        if self.args.sign:
-            extra["signed"] = True
-        publish_options = NodeCreate.get_config_options(self.args)
-        if publish_options:
-            extra["publish_options"] = publish_options
-
-        try:
-            published_id = await self.host.bridge.ps_item_send(
-                self.args.service,
-                self.args.node,
-                payload,
-                self.args.item,
-                data_format.serialise(extra),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't send item: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if published_id:
-                if self.args.quiet:
-                    self.disp(published_id, end="")
-                else:
-                    self.disp(f"Item published at {published_id}")
-            else:
-                self.disp("Item published")
-            self.host.quit(C.EXIT_OK)
-
-
-class Get(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "get",
-            use_output=C.OUTPUT_LIST_XML,
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.MULTI_ITEMS, C.CACHE},
-            help=_("get pubsub item(s)"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-S",
-            "--sub-id",
-            default="",
-            help=_("subscription id"),
-        )
-        self.parser.add_argument(
-            "--no-decrypt",
-            action="store_true",
-            help=_("don't do automatic decryption of e2ee items"),
-        )
-        #  TODO: a key(s) argument to select keys to display
-
-    async def start(self):
-        extra = {}
-        if self.args.no_decrypt:
-            extra["decrypt"] = False
-        try:
-            ps_result = data_format.deserialise(
-                await self.host.bridge.ps_items_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    self.args.sub_id,
-                    self.get_pubsub_extra(extra),
-                    self.profile,
-                )
-            )
-        except BridgeException as e:
-            if e.condition == "item-not-found" or e.classname == "NotFound":
-                self.disp(
-                    f"The node {self.args.node} doesn't exist on {self.args.service}",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-            else:
-                self.disp(f"can't get pubsub items: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        except Exception as e:
-            self.disp(f"Internal error: {e}", error=True)
-            self.host.quit(C.EXIT_INTERNAL_ERROR)
-        else:
-            await self.output(ps_result["items"])
-            self.host.quit(C.EXIT_OK)
-
-
-class Delete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.ITEM, C.SINGLE_ITEM},
-            help=_("delete an item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--force", action="store_true", help=_("delete without confirmation")
-        )
-        self.parser.add_argument(
-            "--no-notification", dest="notify", action="store_false",
-            help=_("do not send notification (not recommended)")
-        )
-
-    async def start(self):
-        if not self.args.item:
-            self.parser.error(_("You need to specify an item to delete"))
-        if not self.args.force:
-            message = _("Are you sure to delete item {item_id} ?").format(
-                item_id=self.args.item
-            )
-            await self.host.confirm_or_quit(message, _("item deletion cancelled"))
-        try:
-            await self.host.bridge.ps_item_retract(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.notify,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't delete item: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("item {item} has been deleted").format(item=self.args.item))
-            self.host.quit(C.EXIT_OK)
-
-
-class Edit(base.CommandBase, common.BaseEdit):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "edit",
-            use_verbose=True,
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            use_draft=True,
-            help=_("edit an existing or new pubsub item"),
-        )
-        common.BaseEdit.__init__(self, self.host, PUBSUB_TMP_DIR)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-e",
-            "--encrypt",
-            action="store_true",
-            help=_("end-to-end encrypt the blog item")
-        )
-        self.parser.add_argument(
-            "--encrypt-for",
-            metavar="JID",
-            action="append",
-            help=_("encrypt a single item for")
-        )
-        self.parser.add_argument(
-            "-X",
-            "--sign",
-            action="store_true",
-            help=_("cryptographically sign the blog post")
-        )
-
-    async def publish(self, content):
-        extra = {}
-        if self.args.encrypt:
-            extra["encrypted"] = True
-        if self.args.encrypt_for:
-            extra["encrypted_for"] = {"targets": self.args.encrypt_for}
-        if self.args.sign:
-            extra["signed"] = True
-        published_id = await self.host.bridge.ps_item_send(
-            self.pubsub_service,
-            self.pubsub_node,
-            content,
-            self.pubsub_item or "",
-            data_format.serialise(extra),
-            self.profile,
-        )
-        if published_id:
-            self.disp("Item published at {pub_id}".format(pub_id=published_id))
-        else:
-            self.disp("Item published")
-
-    async def get_item_data(self, service, node, item):
-        try:
-            from lxml import etree
-        except ImportError:
-            self.disp(
-                "lxml module must be installed to use edit, please install it "
-                'with "pip install lxml"',
-                error=True,
-            )
-            self.host.quit(1)
-        items = [item] if item else []
-        ps_result = data_format.deserialise(
-            await self.host.bridge.ps_items_get(
-                service, node, 1, items, "", data_format.serialise({}), self.profile
-            )
-        )
-        item_raw = ps_result["items"][0]
-        parser = etree.XMLParser(remove_blank_text=True, recover=True)
-        item_elt = etree.fromstring(item_raw, parser)
-        item_id = item_elt.get("id")
-        try:
-            payload = item_elt[0]
-        except IndexError:
-            self.disp(_("Item has not payload"), 1)
-            return "", item_id
-        return etree.tostring(payload, encoding="unicode", pretty_print=True), item_id
-
-    async def start(self):
-        (
-            self.pubsub_service,
-            self.pubsub_node,
-            self.pubsub_item,
-            content_file_path,
-            content_file_obj,
-        ) = await self.get_item_path()
-        await self.run_editor("pubsub_editor_args", content_file_path, content_file_obj)
-        self.host.quit()
-
-
-class Rename(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "rename",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("rename a pubsub item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("new_id", help=_("new item id to use"))
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_item_rename(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.new_id,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't rename item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("Item renamed")
-            self.host.quit(C.EXIT_OK)
-
-
-class Subscribe(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "subscribe",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("subscribe to a node"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--public",
-            action="store_true",
-            help=_("make the registration visible for everybody"),
-        )
-
-    async def start(self):
-        options = {}
-        if self.args.public:
-            namespaces = await self.host.bridge.namespaces_get()
-            try:
-                ns_pps = namespaces["pps"]
-            except KeyError:
-                self.disp(
-                    "Pubsub Public Subscription plugin is not loaded, can't use --public "
-                    "option, subscription stopped", error=True
-                )
-                self.host.quit(C.EXIT_MISSING_FEATURE)
-            else:
-                options[f"{{{ns_pps}}}public"] = True
-        try:
-            sub_id = await self.host.bridge.ps_subscribe(
-                self.args.service,
-                self.args.node,
-                data_format.serialise(options),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't subscribe to node: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("subscription done"), 1)
-            if sub_id:
-                self.disp(_("subscription id: {sub_id}").format(sub_id=sub_id))
-            self.host.quit()
-
-
-class Unsubscribe(base.CommandBase):
-    # FIXME: check why we get a a NodeNotFound on subscribe just after unsubscribe
-
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "unsubscribe",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            use_verbose=True,
-            help=_("unsubscribe from a node"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_unsubscribe(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(_("can't unsubscribe from node: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("subscription removed"), 1)
-            self.host.quit()
-
-
-class Subscriptions(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "subscriptions",
-            use_output=C.OUTPUT_LIST_DICT,
-            use_pubsub=True,
-            help=_("retrieve all subscriptions on a service"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--public",
-            action="store_true",
-            help=_("get public subscriptions"),
-        )
-
-    async def start(self):
-        if self.args.public:
-            method = self.host.bridge.ps_public_subscriptions_get
-        else:
-            method = self.host.bridge.ps_subscriptions_get
-        try:
-            subscriptions = data_format.deserialise(
-                await method(
-                    self.args.service,
-                    self.args.node,
-                    self.profile,
-                ),
-                type_check=list
-            )
-        except Exception as e:
-            self.disp(_("can't retrieve subscriptions: {e}").format(e=e), error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(subscriptions)
-            self.host.quit()
-
-
-class Affiliations(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "affiliations",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            help=_("retrieve all affiliations on a service"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            affiliations = await self.host.bridge.ps_affiliations_get(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get node affiliations: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(affiliations)
-            self.host.quit()
-
-
-class Reference(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "reference",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("send a reference/mention to pubsub item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            default="mention",
-            choices=("data", "mention"),
-            help=_("type of reference to send (DEFAULT: mention)"),
-        )
-        self.parser.add_argument(
-            "recipient",
-            help=_("recipient of the reference")
-        )
-
-    async def start(self):
-        service = self.args.service or await self.host.get_profile_jid()
-        if self.args.item:
-            anchor = uri.build_xmpp_uri(
-                "pubsub", path=service, node=self.args.node, item=self.args.item
-            )
-        else:
-            anchor = uri.build_xmpp_uri("pubsub", path=service, node=self.args.node)
-
-        try:
-            await self.host.bridge.reference_send(
-                self.args.recipient,
-                anchor,
-                self.args.type,
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't send reference: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class Search(base.CommandBase):
-    """This command do a search without using MAM
-
-    This commands checks every items it finds by itself,
-    so it may be heavy in resources both for server and client
-    """
-
-    RE_FLAGS = re.MULTILINE | re.UNICODE
-    EXEC_ACTIONS = ("exec", "external")
-
-    def __init__(self, host):
-        # FIXME: C.NO_MAX is not needed here, and this can be globally removed from consts
-        #        the only interest is to change the help string, but this can be explained
-        #        extensively in man pages (max is for each node found)
-        base.CommandBase.__init__(
-            self,
-            host,
-            "search",
-            use_output=C.OUTPUT_XML,
-            use_pubsub=True,
-            pubsub_flags={C.MULTI_ITEMS, C.NO_MAX},
-            use_verbose=True,
-            help=_("search items corresponding to filters"),
-        )
-
-    @property
-    def etree(self):
-        """load lxml.etree only if needed"""
-        if self._etree is None:
-            from lxml import etree
-
-            self._etree = etree
-        return self._etree
-
-    def filter_opt(self, value, type_):
-        return (type_, value)
-
-    def filter_flag(self, value, type_):
-        value = C.bool(value)
-        return (type_, value)
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-D",
-            "--max-depth",
-            type=int,
-            default=0,
-            help=_(
-                "maximum depth of recursion (will search linked nodes if > 0, "
-                "DEFAULT: 0)"
-            ),
-        )
-        self.parser.add_argument(
-            "-M",
-            "--node-max",
-            type=int,
-            default=30,
-            help=_(
-                "maximum number of items to get per node ({} to get all items, "
-                "DEFAULT: 30)".format(C.NO_LIMIT)
-            ),
-        )
-        self.parser.add_argument(
-            "-N",
-            "--namespace",
-            action="append",
-            nargs=2,
-            default=[],
-            metavar="NAME NAMESPACE",
-            help=_("namespace to use for xpath"),
-        )
-
-        # filters
-        filter_text = partial(self.filter_opt, type_="text")
-        filter_re = partial(self.filter_opt, type_="regex")
-        filter_xpath = partial(self.filter_opt, type_="xpath")
-        filter_python = partial(self.filter_opt, type_="python")
-        filters = self.parser.add_argument_group(
-            _("filters"),
-            _("only items corresponding to following filters will be kept"),
-        )
-        filters.add_argument(
-            "-t",
-            "--text",
-            action="append",
-            dest="filters",
-            type=filter_text,
-            metavar="TEXT",
-            help=_("full text filter, item must contain this string (XML included)"),
-        )
-        filters.add_argument(
-            "-r",
-            "--regex",
-            action="append",
-            dest="filters",
-            type=filter_re,
-            metavar="EXPRESSION",
-            help=_("like --text but using a regular expression"),
-        )
-        filters.add_argument(
-            "-x",
-            "--xpath",
-            action="append",
-            dest="filters",
-            type=filter_xpath,
-            metavar="XPATH",
-            help=_("filter items which has elements matching this xpath"),
-        )
-        filters.add_argument(
-            "-P",
-            "--python",
-            action="append",
-            dest="filters",
-            type=filter_python,
-            metavar="PYTHON_CODE",
-            help=_(
-                "Python expression which much return a bool (True to keep item, "
-                'False to reject it). "item" is raw text item, "item_xml" is '
-                "lxml's etree.Element"
-            ),
-        )
-
-        # filters flags
-        flag_case = partial(self.filter_flag, type_="ignore-case")
-        flag_invert = partial(self.filter_flag, type_="invert")
-        flag_dotall = partial(self.filter_flag, type_="dotall")
-        flag_matching = partial(self.filter_flag, type_="only-matching")
-        flags = self.parser.add_argument_group(
-            _("filters flags"),
-            _("filters modifiers (change behaviour of following filters)"),
-        )
-        flags.add_argument(
-            "-C",
-            "--ignore-case",
-            action="append",
-            dest="filters",
-            type=flag_case,
-            const=("ignore-case", True),
-            nargs="?",
-            metavar="BOOLEAN",
-            help=_("(don't) ignore case in following filters (DEFAULT: case sensitive)"),
-        )
-        flags.add_argument(
-            "-I",
-            "--invert",
-            action="append",
-            dest="filters",
-            type=flag_invert,
-            const=("invert", True),
-            nargs="?",
-            metavar="BOOLEAN",
-            help=_("(don't) invert effect of following filters (DEFAULT: don't invert)"),
-        )
-        flags.add_argument(
-            "-A",
-            "--dot-all",
-            action="append",
-            dest="filters",
-            type=flag_dotall,
-            const=("dotall", True),
-            nargs="?",
-            metavar="BOOLEAN",
-            help=_("(don't) use DOTALL option for regex (DEFAULT: don't use)"),
-        )
-        flags.add_argument(
-            "-k",
-            "--only-matching",
-            action="append",
-            dest="filters",
-            type=flag_matching,
-            const=("only-matching", True),
-            nargs="?",
-            metavar="BOOLEAN",
-            help=_("keep only the matching part of the item"),
-        )
-
-        # action
-        self.parser.add_argument(
-            "action",
-            default="print",
-            nargs="?",
-            choices=("print", "exec", "external"),
-            help=_("action to do on found items (DEFAULT: print)"),
-        )
-        self.parser.add_argument("command", nargs=argparse.REMAINDER)
-
-    async def get_items(self, depth, service, node, items):
-        self.to_get += 1
-        try:
-            ps_result = data_format.deserialise(
-                await self.host.bridge.ps_items_get(
-                    service,
-                    node,
-                    self.args.node_max,
-                    items,
-                    "",
-                    self.get_pubsub_extra(),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(
-                f"can't get pubsub items at {service} (node: {node}): {e}",
-                error=True,
-            )
-            self.to_get -= 1
-        else:
-            await self.search(ps_result, depth)
-
-    def _check_pubsub_url(self, match, found_nodes):
-        """check that the matched URL is an xmpp: one
-
-        @param found_nodes(list[unicode]): found_nodes
-            this list will be filled while xmpp: URIs are discovered
-        """
-        url = match.group(0)
-        if url.startswith("xmpp"):
-            try:
-                url_data = uri.parse_xmpp_uri(url)
-            except ValueError:
-                return
-            if url_data["type"] == "pubsub":
-                found_node = {"service": url_data["path"], "node": url_data["node"]}
-                if "item" in url_data:
-                    found_node["item"] = url_data["item"]
-                found_nodes.append(found_node)
-
-    async def get_sub_nodes(self, item, depth):
-        """look for pubsub URIs in item, and get_items on the linked nodes"""
-        found_nodes = []
-        checkURI = partial(self._check_pubsub_url, found_nodes=found_nodes)
-        strings.RE_URL.sub(checkURI, item)
-        for data in found_nodes:
-            await self.get_items(
-                depth + 1,
-                data["service"],
-                data["node"],
-                [data["item"]] if "item" in data else [],
-            )
-
-    def parseXml(self, item):
-        try:
-            return self.etree.fromstring(item)
-        except self.etree.XMLSyntaxError:
-            self.disp(
-                _(
-                    "item doesn't looks like XML, you have probably used --only-matching "
-                    "somewhere before and we have no more XML"
-                ),
-                error=True,
-            )
-            self.host.quit(C.EXIT_BAD_ARG)
-
-    def filter(self, item):
-        """apply filters given on command line
-
-        if only-matching is used, item may be modified
-        @return (tuple[bool, unicode]): a tuple with:
-            - keep: True if item passed the filters
-            - item: it is returned in case of modifications
-        """
-        ignore_case = False
-        invert = False
-        dotall = False
-        only_matching = False
-        item_xml = None
-        for type_, value in self.args.filters:
-            keep = True
-
-            ## filters
-
-            if type_ == "text":
-                if ignore_case:
-                    if value.lower() not in item.lower():
-                        keep = False
-                else:
-                    if value not in item:
-                        keep = False
-                if keep and only_matching:
-                    # doesn't really make sens to keep a fixed string
-                    # so we raise an error
-                    self.host.disp(
-                        _("--only-matching used with fixed --text string, are you sure?"),
-                        error=True,
-                    )
-                    self.host.quit(C.EXIT_BAD_ARG)
-            elif type_ == "regex":
-                flags = self.RE_FLAGS
-                if ignore_case:
-                    flags |= re.IGNORECASE
-                if dotall:
-                    flags |= re.DOTALL
-                match = re.search(value, item, flags)
-                keep = match != None
-                if keep and only_matching:
-                    item = match.group()
-                    item_xml = None
-            elif type_ == "xpath":
-                if item_xml is None:
-                    item_xml = self.parseXml(item)
-                try:
-                    elts = item_xml.xpath(value, namespaces=self.args.namespace)
-                except self.etree.XPathEvalError as e:
-                    self.disp(_("can't use xpath: {reason}").format(reason=e), error=True)
-                    self.host.quit(C.EXIT_BAD_ARG)
-                keep = bool(elts)
-                if keep and only_matching:
-                    item_xml = elts[0]
-                    try:
-                        item = self.etree.tostring(item_xml, encoding="unicode")
-                    except TypeError:
-                        # we have a string only, not an element
-                        item = str(item_xml)
-                        item_xml = None
-            elif type_ == "python":
-                if item_xml is None:
-                    item_xml = self.parseXml(item)
-                cmd_ns = {"etree": self.etree, "item": item, "item_xml": item_xml}
-                try:
-                    keep = eval(value, cmd_ns)
-                except SyntaxError as e:
-                    self.disp(str(e), error=True)
-                    self.host.quit(C.EXIT_BAD_ARG)
-
-                    ## flags
-
-            elif type_ == "ignore-case":
-                ignore_case = value
-            elif type_ == "invert":
-                invert = value
-                #  we need to continue, else loop would end here
-                continue
-            elif type_ == "dotall":
-                dotall = value
-            elif type_ == "only-matching":
-                only_matching = value
-            else:
-                raise exceptions.InternalError(
-                    _("unknown filter type {type}").format(type=type_)
-                )
-
-            if invert:
-                keep = not keep
-            if not keep:
-                return False, item
-
-        return True, item
-
-    async def do_item_action(self, item, metadata):
-        """called when item has been kepts and the action need to be done
-
-        @param item(unicode): accepted item
-        """
-        action = self.args.action
-        if action == "print" or self.host.verbosity > 0:
-            try:
-                await self.output(item)
-            except self.etree.XMLSyntaxError:
-                # item is not valid XML, but a string
-                # can happen when --only-matching is used
-                self.disp(item)
-        if action in self.EXEC_ACTIONS:
-            item_elt = self.parseXml(item)
-            if action == "exec":
-                use = {
-                    "service": metadata["service"],
-                    "node": metadata["node"],
-                    "item": item_elt.get("id"),
-                    "profile": self.profile,
-                }
-                # we need to send a copy of self.args.command
-                # else it would be modified
-                parser_args, use_args = arg_tools.get_use_args(
-                    self.host, self.args.command, use, verbose=self.host.verbosity > 1
-                )
-                cmd_args = sys.argv[0:1] + parser_args + use_args
-            else:
-                cmd_args = self.args.command
-
-            self.disp(
-                "COMMAND: {command}".format(
-                    command=" ".join([arg_tools.escape(a) for a in cmd_args])
-                ),
-                2,
-            )
-            if action == "exec":
-                p = await asyncio.create_subprocess_exec(*cmd_args)
-                ret = await p.wait()
-            else:
-                p = await asyncio.create_subprocess_exec(*cmd_args, stdin=subprocess.PIPE)
-                await p.communicate(item.encode(sys.getfilesystemencoding()))
-                ret = p.returncode
-            if ret != 0:
-                self.disp(
-                    A.color(
-                        C.A_FAILURE,
-                        _("executed command failed with exit code {ret}").format(ret=ret),
-                    )
-                )
-
-    async def search(self, ps_result, depth):
-        """callback of get_items
-
-        this method filters items, get sub nodes if needed,
-        do the requested action, and exit the command when everything is done
-        @param items_data(tuple): result of get_items
-        @param depth(int): current depth level
-            0 for first node, 1 for first children, and so on
-        """
-        for item in ps_result["items"]:
-            if depth < self.args.max_depth:
-                await self.get_sub_nodes(item, depth)
-            keep, item = self.filter(item)
-            if not keep:
-                continue
-            await self.do_item_action(item, ps_result)
-
-            #  we check if we got all get_items results
-        self.to_get -= 1
-        if self.to_get == 0:
-            # yes, we can quit
-            self.host.quit()
-        assert self.to_get > 0
-
-    async def start(self):
-        if self.args.command:
-            if self.args.action not in self.EXEC_ACTIONS:
-                self.parser.error(
-                    _("Command can only be used with {actions} actions").format(
-                        actions=", ".join(self.EXEC_ACTIONS)
-                    )
-                )
-        else:
-            if self.args.action in self.EXEC_ACTIONS:
-                self.parser.error(_("you need to specify a command to execute"))
-        if not self.args.node:
-            # TODO: handle get service affiliations when node is not set
-            self.parser.error(_("empty node is not handled yet"))
-            # to_get is increased on each get and decreased on each answer
-            # when it reach 0 again, the command is finished
-        self.to_get = 0
-        self._etree = None
-        if self.args.filters is None:
-            self.args.filters = []
-        self.args.namespace = dict(
-            self.args.namespace + [("pubsub", "http://jabber.org/protocol/pubsub")]
-        )
-        await self.get_items(0, self.args.service, self.args.node, self.args.items)
-
-
-class Transform(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "transform",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.MULTI_ITEMS},
-            help=_("modify items of a node using an external command/script"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--apply",
-            action="store_true",
-            help=_("apply transformation (DEFAULT: do a dry run)"),
-        )
-        self.parser.add_argument(
-            "--admin",
-            action="store_true",
-            help=_("do a pubsub admin request, needed to change publisher"),
-        )
-        self.parser.add_argument(
-            "-I",
-            "--ignore-errors",
-            action="store_true",
-            help=_(
-                "if command return a non zero exit code, ignore the item and continue"
-            ),
-        )
-        self.parser.add_argument(
-            "-A",
-            "--all",
-            action="store_true",
-            help=_("get all items by looping over all pages using RSM"),
-        )
-        self.parser.add_argument(
-            "command_path",
-            help=_(
-                "path to the command to use. Will be called repetitivly with an "
-                "item as input. Output (full item XML) will be used as new one. "
-                'Return "DELETE" string to delete the item, and "SKIP" to ignore it'
-            ),
-        )
-
-    async def ps_items_send_cb(self, item_ids, metadata):
-        if item_ids:
-            self.disp(
-                _("items published with ids {item_ids}").format(
-                    item_ids=", ".join(item_ids)
-                )
-            )
-        else:
-            self.disp(_("items published"))
-        if self.args.all:
-            return await self.handle_next_page(metadata)
-        else:
-            self.host.quit()
-
-    async def handle_next_page(self, metadata):
-        """Retrieve new page through RSM or quit if we're in the last page
-
-        use to handle --all option
-        @param metadata(dict): metadata as returned by ps_items_get
-        """
-        try:
-            last = metadata["rsm"]["last"]
-            index = int(metadata["rsm"]["index"])
-            count = int(metadata["rsm"]["count"])
-        except KeyError:
-            self.disp(
-                _("Can't retrieve all items, RSM metadata not available"), error=True
-            )
-            self.host.quit(C.EXIT_MISSING_FEATURE)
-        except ValueError as e:
-            self.disp(
-                _("Can't retrieve all items, bad RSM metadata: {msg}").format(msg=e),
-                error=True,
-            )
-            self.host.quit(C.EXIT_ERROR)
-
-        if index + self.args.rsm_max >= count:
-            self.disp(_("All items transformed"))
-            self.host.quit(0)
-
-        self.disp(
-            _("Retrieving next page ({page_idx}/{page_total})").format(
-                page_idx=int(index / self.args.rsm_max) + 1,
-                page_total=int(count / self.args.rsm_max),
-            )
-        )
-
-        extra = self.get_pubsub_extra()
-        extra["rsm_after"] = last
-        try:
-            ps_result = await data_format.deserialise(
-                self.host.bridge.ps_items_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.rsm_max,
-                    self.args.items,
-                    "",
-                    data_format.serialise(extra),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't retrieve items: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.ps_items_get_cb(ps_result)
-
-    async def ps_items_get_cb(self, ps_result):
-        encoding = "utf-8"
-        new_items = []
-
-        for item in ps_result["items"]:
-            if self.check_duplicates:
-                # this is used when we are not ordering by creation
-                # to avoid infinite loop
-                item_elt, __ = xml_tools.etree_parse(self, item)
-                item_id = item_elt.get("id")
-                if item_id in self.items_ids:
-                    self.disp(
-                        _(
-                            "Duplicate found on item {item_id}, we have probably handled "
-                            "all items."
-                        ).format(item_id=item_id)
-                    )
-                    self.host.quit()
-                self.items_ids.append(item_id)
-
-                # we launch the command to filter the item
-            try:
-                p = await asyncio.create_subprocess_exec(
-                    self.args.command_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE
-                )
-            except OSError as e:
-                exit_code = C.EXIT_CMD_NOT_FOUND if e.errno == 2 else C.EXIT_ERROR
-                self.disp(f"Can't execute the command: {e}", error=True)
-                self.host.quit(exit_code)
-            encoding = "utf-8"
-            cmd_std_out, cmd_std_err = await p.communicate(item.encode(encoding))
-            ret = p.returncode
-            if ret != 0:
-                self.disp(
-                    f"The command returned a non zero status while parsing the "
-                    f"following item:\n\n{item}",
-                    error=True,
-                )
-                if self.args.ignore_errors:
-                    continue
-                else:
-                    self.host.quit(C.EXIT_CMD_ERROR)
-            if cmd_std_err is not None:
-                cmd_std_err = cmd_std_err.decode(encoding, errors="ignore")
-                self.disp(cmd_std_err, error=True)
-            cmd_std_out = cmd_std_out.decode(encoding).strip()
-            if cmd_std_out == "DELETE":
-                item_elt, __ = xml_tools.etree_parse(self, item)
-                item_id = item_elt.get("id")
-                self.disp(_("Deleting item {item_id}").format(item_id=item_id))
-                if self.args.apply:
-                    try:
-                        await self.host.bridge.ps_item_retract(
-                            self.args.service,
-                            self.args.node,
-                            item_id,
-                            False,
-                            self.profile,
-                        )
-                    except Exception as e:
-                        self.disp(f"can't delete item {item_id}: {e}", error=True)
-                        self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-                continue
-            elif cmd_std_out == "SKIP":
-                item_elt, __ = xml_tools.etree_parse(self, item)
-                item_id = item_elt.get("id")
-                self.disp(_("Skipping item {item_id}").format(item_id=item_id))
-                continue
-            element, etree = xml_tools.etree_parse(self, cmd_std_out)
-
-            # at this point command has been run and we have a etree.Element object
-            if element.tag not in ("item", "{http://jabber.org/protocol/pubsub}item"):
-                self.disp(
-                    "your script must return a whole item, this is not:\n{xml}".format(
-                        xml=etree.tostring(element, encoding="unicode")
-                    ),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_DATA_ERROR)
-
-            if not self.args.apply:
-                # we have a dry run, we just display filtered items
-                serialised = etree.tostring(
-                    element, encoding="unicode", pretty_print=True
-                )
-                self.disp(serialised)
-            else:
-                new_items.append(etree.tostring(element, encoding="unicode"))
-
-        if not self.args.apply:
-            # on dry run we have nothing to wait for, we can quit
-            if self.args.all:
-                return await self.handle_next_page(ps_result)
-            self.host.quit()
-        else:
-            if self.args.admin:
-                bridge_method = self.host.bridge.ps_admin_items_send
-            else:
-                bridge_method = self.host.bridge.ps_items_send
-
-            try:
-                ps_items_send_result = await bridge_method(
-                    self.args.service,
-                    self.args.node,
-                    new_items,
-                    "",
-                    self.profile,
-                )
-            except Exception as e:
-                self.disp(f"can't send item: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                await self.ps_items_send_cb(ps_items_send_result, metadata=ps_result)
-
-    async def start(self):
-        if self.args.all and self.args.order_by != C.ORDER_BY_CREATION:
-            self.check_duplicates = True
-            self.items_ids = []
-            self.disp(
-                A.color(
-                    A.FG_RED,
-                    A.BOLD,
-                    '/!\\ "--all" should be used with "--order-by creation" /!\\\n',
-                    A.RESET,
-                    "We'll update items, so order may change during transformation,\n"
-                    "we'll try to mitigate that by stopping on first duplicate,\n"
-                    "but this method is not safe, and some items may be missed.\n---\n",
-                )
-            )
-        else:
-            self.check_duplicates = False
-
-        try:
-            ps_result = data_format.deserialise(
-                await self.host.bridge.ps_items_get(
-                    self.args.service,
-                    self.args.node,
-                    self.args.max,
-                    self.args.items,
-                    "",
-                    self.get_pubsub_extra(),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't retrieve items: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.ps_items_get_cb(ps_result)
-
-
-class Uri(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "uri",
-            use_profile=False,
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("build URI"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-p",
-            "--profile",
-            default=C.PROF_KEY_DEFAULT,
-            help=_("profile (used when no server is specified)"),
-        )
-
-    def display_uri(self, jid_):
-        uri_args = {}
-        if not self.args.service:
-            self.args.service = jid.JID(jid_).bare
-
-        for key in ("node", "service", "item"):
-            value = getattr(self.args, key)
-            if key == "service":
-                key = "path"
-            if value:
-                uri_args[key] = value
-        self.disp(uri.build_xmpp_uri("pubsub", **uri_args))
-        self.host.quit()
-
-    async def start(self):
-        if not self.args.service:
-            try:
-                jid_ = await self.host.bridge.param_get_a_async(
-                    "JabberID", "Connection", profile_key=self.args.profile
-                )
-            except Exception as e:
-                self.disp(f"can't retrieve jid: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            else:
-                self.display_uri(jid_)
-        else:
-            self.display_uri(None)
-
-
-class AttachmentGet(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "get",
-            use_output=C.OUTPUT_LIST_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
-            help=_("get data attached to an item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-j",
-            "--jid",
-            action="append",
-            dest="jids",
-            help=_(
-                "get attached data published only by those JIDs (DEFAULT: get all "
-                "attached data)"
-            )
-        )
-
-    async def start(self):
-        try:
-            attached_data, __ = await self.host.bridge.ps_attachments_get(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.jids or [],
-                "",
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't get attached data: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            attached_data = data_format.deserialise(attached_data, type_check=list)
-            await self.output(attached_data)
-            self.host.quit(C.EXIT_OK)
-
-
-class AttachmentSet(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "set",
-            use_pubsub=True,
-            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
-            help=_("attach data to an item"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--replace",
-            action="store_true",
-            help=_(
-                "replace previous versions of attachments (DEFAULT: update previous "
-                "version)"
-            )
-        )
-        self.parser.add_argument(
-            "-N",
-            "--noticed",
-            metavar="BOOLEAN",
-            nargs="?",
-            default="keep",
-            help=_("mark item as (un)noticed (DEFAULT: keep current value))")
-        )
-        self.parser.add_argument(
-            "-r",
-            "--reactions",
-            # FIXME: to be replaced by "extend" when we stop supporting python 3.7
-            action="append",
-            help=_("emojis to add to react to an item")
-        )
-        self.parser.add_argument(
-            "-R",
-            "--reactions-remove",
-            # FIXME: to be replaced by "extend" when we stop supporting python 3.7
-            action="append",
-            help=_("emojis to remove from reactions to an item")
-        )
-
-    async def start(self):
-        attachments_data = {
-            "service": self.args.service,
-            "node": self.args.node,
-            "id": self.args.item,
-            "extra": {}
-        }
-        operation = "replace" if self.args.replace else "update"
-        if self.args.noticed != "keep":
-            if self.args.noticed is None:
-                self.args.noticed = C.BOOL_TRUE
-            attachments_data["extra"]["noticed"] = C.bool(self.args.noticed)
-
-        if self.args.reactions or self.args.reactions_remove:
-            reactions = attachments_data["extra"]["reactions"] = {
-                "operation": operation
-            }
-            if self.args.replace:
-                reactions["reactions"] = self.args.reactions
-            else:
-                reactions["add"] = self.args.reactions
-                reactions["remove"] = self.args.reactions_remove
-
-
-        if not attachments_data["extra"]:
-            self.parser.error(_("At leat one attachment must be specified."))
-
-        try:
-            await self.host.bridge.ps_attachments_set(
-                data_format.serialise(attachments_data),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't attach data to item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("data attached")
-            self.host.quit(C.EXIT_OK)
-
-
-class Attachments(base.CommandBase):
-    subcommands = (AttachmentGet, AttachmentSet)
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "attachments",
-            use_profile=False,
-            help=_("set or retrieve items attachments"),
-        )
-
-
-class SignatureSign(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "sign",
-            use_pubsub=True,
-            pubsub_flags={C.NODE, C.SINGLE_ITEM},
-            help=_("sign an item"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        attachments_data = {
-            "service": self.args.service,
-            "node": self.args.node,
-            "id": self.args.item,
-            "extra": {
-                # we set None to use profile's bare JID
-                "signature": {"signer": None}
-            }
-        }
-        try:
-            await self.host.bridge.ps_attachments_set(
-                data_format.serialise(attachments_data),
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't sign the item: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(f"item {self.args.item!r} has been signed")
-            self.host.quit(C.EXIT_OK)
-
-
-class SignatureCheck(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "check",
-            use_output=C.OUTPUT_DICT,
-            use_pubsub=True,
-            pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM},
-            help=_("check the validity of pubsub signature"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "signature",
-            metavar="JSON",
-            help=_("signature data")
-        )
-
-    async def start(self):
-        try:
-            ret_s = await self.host.bridge.ps_signature_check(
-                self.args.service,
-                self.args.node,
-                self.args.item,
-                self.args.signature,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't check signature: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self.output(data_format.deserialise((ret_s)))
-            self.host.quit()
-
-
-class Signature(base.CommandBase):
-    subcommands = (
-        SignatureSign,
-        SignatureCheck,
-    )
-
-    def __init__(self, host):
-        super().__init__(
-            host, "signature", use_profile=False, help=_("items signatures")
-        )
-
-
-class SecretShare(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "share",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("share a secret to let other entity encrypt or decrypt items"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-k", "--key", metavar="ID", dest="secret_ids", action="append", default=[],
-            help=_(
-                "only share secrets with those IDs (default: share all secrets of the "
-                "node)"
-            )
-        )
-        self.parser.add_argument(
-            "recipient", metavar="JID", help=_("entity who must get the shared secret")
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_secret_share(
-                self.args.recipient,
-                self.args.service,
-                self.args.node,
-                self.args.secret_ids,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't share secret: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("secrets have been shared")
-            self.host.quit(C.EXIT_OK)
-
-
-class SecretRevoke(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "revoke",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("revoke an encrypted node secret"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "secret_id", help=_("ID of the secrets to revoke")
-        )
-        self.parser.add_argument(
-            "-r", "--recipient", dest="recipients", metavar="JID", action="append",
-            default=[], help=_(
-                "entity who must get the revocation notification (default: send to all "
-                "entities known to have the shared secret)"
-            )
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_secret_revoke(
-                self.args.service,
-                self.args.node,
-                self.args.secret_id,
-                self.args.recipients,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't revoke secret: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("secret {self.args.secret_id} has been revoked.")
-            self.host.quit(C.EXIT_OK)
-
-
-class SecretRotate(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "rotate",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("revoke existing secrets, create a new one and send notifications"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-r", "--recipient", dest="recipients", metavar="JID", action="append",
-            default=[], help=_(
-                "entity who must get the revocation and shared secret notifications "
-                "(default: send to all entities known to have the shared secret)"
-            )
-        )
-
-    async def start(self):
-        try:
-            await self.host.bridge.ps_secret_rotate(
-                self.args.service,
-                self.args.node,
-                self.args.recipients,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't rotate secret: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp("secret has been rotated")
-            self.host.quit(C.EXIT_OK)
-
-
-class SecretList(base.CommandBase):
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "list",
-            use_pubsub=True,
-            use_verbose=True,
-            pubsub_flags={C.NODE},
-            help=_("list known secrets for a pubsub node"),
-            use_output=C.OUTPUT_LIST_DICT
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            secrets = data_format.deserialise(await self.host.bridge.ps_secrets_list(
-                self.args.service,
-                self.args.node,
-                self.profile,
-            ), type_check=list)
-        except Exception as e:
-            self.disp(f"can't list node secrets: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if not self.verbosity:
-                # we don't print key if verbosity is not a least one, to avoid showing it
-                # on the screen accidentally
-                for secret in secrets:
-                    del secret["key"]
-            await self.output(secrets)
-            self.host.quit(C.EXIT_OK)
-
-
-class Secret(base.CommandBase):
-    subcommands = (SecretShare, SecretRevoke, SecretRotate, SecretList)
-
-    def __init__(self, host):
-        super().__init__(
-            host,
-            "secret",
-            use_profile=False,
-            help=_("handle encrypted nodes secrets"),
-        )
-
-
-class HookCreate(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "create",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("create a Pubsub hook"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            default="python",
-            choices=("python", "python_file", "python_code"),
-            help=_("hook type"),
-        )
-        self.parser.add_argument(
-            "-P",
-            "--persistent",
-            action="store_true",
-            help=_("make hook persistent across restarts"),
-        )
-        self.parser.add_argument(
-            "hook_arg",
-            help=_("argument of the hook (depend of the type)"),
-        )
-
-    @staticmethod
-    def check_args(self):
-        if self.args.type == "python_file":
-            self.args.hook_arg = os.path.abspath(self.args.hook_arg)
-            if not os.path.isfile(self.args.hook_arg):
-                self.parser.error(
-                    _("{path} is not a file").format(path=self.args.hook_arg)
-                )
-
-    async def start(self):
-        self.check_args(self)
-        try:
-            await self.host.bridge.ps_hook_add(
-                self.args.service,
-                self.args.node,
-                self.args.type,
-                self.args.hook_arg,
-                self.args.persistent,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't create hook: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.host.quit()
-
-
-class HookDelete(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "delete",
-            use_pubsub=True,
-            pubsub_flags={C.NODE},
-            help=_("delete a Pubsub hook"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-t",
-            "--type",
-            default="",
-            choices=("", "python", "python_file", "python_code"),
-            help=_("hook type to remove, empty to remove all (DEFAULT: remove all)"),
-        )
-        self.parser.add_argument(
-            "-a",
-            "--arg",
-            dest="hook_arg",
-            default="",
-            help=_(
-                "argument of the hook to remove, empty to remove all (DEFAULT: remove all)"
-            ),
-        )
-
-    async def start(self):
-        HookCreate.check_args(self)
-        try:
-            nb_deleted = await self.host.bridge.ps_hook_remove(
-                self.args.service,
-                self.args.node,
-                self.args.type,
-                self.args.hook_arg,
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't delete hook: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(
-                _("{nb_deleted} hook(s) have been deleted").format(nb_deleted=nb_deleted)
-            )
-            self.host.quit()
-
-
-class HookList(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "list",
-            use_output=C.OUTPUT_LIST_DICT,
-            help=_("list hooks of a profile"),
-        )
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            data = await self.host.bridge.ps_hook_list(
-                self.profile,
-            )
-        except Exception as e:
-            self.disp(f"can't list hooks: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            if not data:
-                self.disp(_("No hook found."))
-            await self.output(data)
-            self.host.quit()
-
-
-class Hook(base.CommandBase):
-    subcommands = (HookCreate, HookDelete, HookList)
-
-    def __init__(self, host):
-        super(Hook, self).__init__(
-            host,
-            "hook",
-            use_profile=False,
-            use_verbose=True,
-            help=_("trigger action on Pubsub notifications"),
-        )
-
-
-class Pubsub(base.CommandBase):
-    subcommands = (
-        Set,
-        Get,
-        Delete,
-        Edit,
-        Rename,
-        Subscribe,
-        Unsubscribe,
-        Subscriptions,
-        Affiliations,
-        Reference,
-        Search,
-        Transform,
-        Attachments,
-        Signature,
-        Secret,
-        Hook,
-        Uri,
-        Node,
-        Cache,
-    )
-
-    def __init__(self, host):
-        super(Pubsub, self).__init__(
-            host, "pubsub", use_profile=False, help=_("PubSub nodes/items management")
-        )
--- a/sat_frontends/jp/cmd_roster.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,327 +0,0 @@
-#!/usr/bin/env python3
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2003-2016 Adrien Cossa (souliane@mailoo.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 . import base
-from collections import OrderedDict
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.tools import jid
-from libervia.backend.tools.common.ansi import ANSI as A
-
-__commands__ = ["Roster"]
-
-
-class Get(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(
-            host, 'get', use_output=C.OUTPUT_DICT, use_verbose=True,
-            extra_outputs = {"default": self.default_output},
-            help=_('retrieve the roster entities'))
-
-    def add_parser_options(self):
-        pass
-
-    def default_output(self, data):
-        for contact_jid, contact_data in data.items():
-            all_keys = list(contact_data.keys())
-            keys_to_show = []
-            name = contact_data.get('name', contact_jid.node)
-
-            if self.verbosity >= 1:
-                keys_to_show.append('groups')
-                all_keys.remove('groups')
-            if self.verbosity >= 2:
-                keys_to_show.extend(all_keys)
-
-            if name is None:
-                self.disp(A.color(C.A_HEADER, contact_jid))
-            else:
-                self.disp(A.color(C.A_HEADER, name, A.RESET, f" ({contact_jid})"))
-            for k in keys_to_show:
-                value = contact_data[k]
-                if value:
-                    if isinstance(value, list):
-                        value = ', '.join(value)
-                    self.disp(A.color(
-                        "    ", C.A_SUBHEADER, f"{k}: ", A.RESET, str(value)))
-
-    async def start(self):
-        try:
-            contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile)
-        except Exception as e:
-            self.disp(f"error while retrieving the contacts: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        contacts_dict = {}
-        for contact_jid_s, data, groups in contacts:
-            # FIXME: we have to convert string to bool here for historical reason
-            #        contacts_get format should be changed and serialised properly
-            for key in ('from', 'to', 'ask'):
-                if key in data:
-                    data[key] = C.bool(data[key])
-            data['groups'] = list(groups)
-            contacts_dict[jid.JID(contact_jid_s)] = data
-
-        await self.output(contacts_dict)
-        self.host.quit()
-
-
-class Set(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(host, 'set', help=_('set metadata for a roster entity'))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-n", "--name", default="", help=_('name to use for this entity'))
-        self.parser.add_argument(
-            "-g", "--group", dest='groups', action='append', metavar='GROUP', default=[],
-            help=_('groups for this entity'))
-        self.parser.add_argument(
-            "-R", "--replace", action="store_true",
-            help=_("replace all metadata instead of adding them"))
-        self.parser.add_argument(
-            "jid", help=_("jid of the roster entity"))
-
-    async def start(self):
-
-        if self.args.replace:
-            name = self.args.name
-            groups = self.args.groups
-        else:
-            try:
-                entity_data = await self.host.bridge.contact_get(
-                    self.args.jid, self.host.profile)
-            except Exception as e:
-                self.disp(f"error while retrieving the contact: {e}", error=True)
-                self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-            name = self.args.name or entity_data[0].get('name') or ''
-            groups = set(entity_data[1])
-            groups = list(groups.union(self.args.groups))
-
-        try:
-            await self.host.bridge.contact_update(
-                self.args.jid, name, groups, self.host.profile)
-        except Exception as e:
-            self.disp(f"error while updating the contact: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        self.host.quit()
-
-
-class Delete(base.CommandBase):
-
-    def __init__(self, host):
-        super().__init__(host, 'delete', help=_('remove an entity from roster'))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "-f", "--force", action="store_true", help=_("delete without confirmation")
-        )
-        self.parser.add_argument(
-            "jid", help=_("jid of the roster entity"))
-
-    async def start(self):
-        if not self.args.force:
-            message = _("Are you sure to delete {entity} from your roster?").format(
-                entity=self.args.jid
-            )
-            await self.host.confirm_or_quit(message, _("entity deletion cancelled"))
-        try:
-            await self.host.bridge.contact_del(
-                self.args.jid, self.host.profile)
-        except Exception as e:
-            self.disp(f"error while deleting the entity: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        self.host.quit()
-
-
-class Stats(base.CommandBase):
-
-    def __init__(self, host):
-        super(Stats, self).__init__(host, 'stats', help=_('Show statistics about a roster'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile)
-        except Exception as e:
-            self.disp(f"error while retrieving the contacts: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        hosts = {}
-        unique_groups = set()
-        no_sub, no_from, no_to, no_group, total_group_subscription = 0, 0, 0, 0, 0
-        for contact, attrs, groups in contacts:
-            from_, to = C.bool(attrs["from"]), C.bool(attrs["to"])
-            if not from_:
-                if not to:
-                    no_sub += 1
-                else:
-                    no_from += 1
-            elif not to:
-                no_to += 1
-
-            host = jid.JID(contact).domain
-
-            hosts.setdefault(host, 0)
-            hosts[host] += 1
-            if groups:
-                unique_groups.update(groups)
-                total_group_subscription += len(groups)
-            if not groups:
-                no_group += 1
-        hosts = OrderedDict(sorted(list(hosts.items()), key=lambda item:-item[1]))
-
-        print()
-        print("Total number of contacts: %d" % len(contacts))
-        print("Number of different hosts: %d" % len(hosts))
-        print()
-        for host, count in hosts.items():
-            print("Contacts on {host}: {count} ({rate:.1f}%)".format(
-                host=host, count=count, rate=100 * float(count) / len(contacts)))
-        print()
-        print("Contacts with no 'from' subscription: %d" % no_from)
-        print("Contacts with no 'to' subscription: %d" % no_to)
-        print("Contacts with no subscription at all: %d" % no_sub)
-        print()
-        print("Total number of groups: %d" % len(unique_groups))
-        try:
-            contacts_per_group = float(total_group_subscription) / len(unique_groups)
-        except ZeroDivisionError:
-            contacts_per_group = 0
-        print("Average contacts per group: {:.1f}".format(contacts_per_group))
-        try:
-            groups_per_contact = float(total_group_subscription) / len(contacts)
-        except ZeroDivisionError:
-            groups_per_contact = 0
-        print(f"Average groups' subscriptions per contact: {groups_per_contact:.1f}")
-        print("Contacts not assigned to any group: %d" % no_group)
-        self.host.quit()
-
-
-class Purge(base.CommandBase):
-
-    def __init__(self, host):
-        super(Purge, self).__init__(
-            host, 'purge',
-            help=_('purge the roster from its contacts with no subscription'))
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "--no-from", action="store_true",
-            help=_("also purge contacts with no 'from' subscription"))
-        self.parser.add_argument(
-            "--no-to", action="store_true",
-            help=_("also purge contacts with no 'to' subscription"))
-
-    async def start(self):
-        try:
-            contacts = await self.host.bridge.contacts_get(self.host.profile)
-        except Exception as e:
-            self.disp(f"error while retrieving the contacts: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-        no_sub, no_from, no_to = [], [], []
-        for contact, attrs, groups in contacts:
-            from_, to = C.bool(attrs["from"]), C.bool(attrs["to"])
-            if not from_:
-                if not to:
-                    no_sub.append(contact)
-                elif self.args.no_from:
-                    no_from.append(contact)
-            elif not to and self.args.no_to:
-                no_to.append(contact)
-        if not no_sub and not no_from and not no_to:
-            self.disp(
-                f"Nothing to do - there's a from and/or to subscription(s) between "
-                f"profile {self.host.profile!r} and each of its contacts"
-            )
-        elif await self.ask_confirmation(no_sub, no_from, no_to):
-            for contact in no_sub + no_from + no_to:
-                try:
-                    await self.host.bridge.contact_del(
-                        contact, profile_key=self.host.profile)
-                except Exception as e:
-                    self.disp(f"can't delete contact {contact!r}: {e}", error=True)
-                else:
-                    self.disp(f"contact {contact!r} has been removed")
-
-        self.host.quit()
-
-    async def ask_confirmation(self, no_sub, no_from, no_to):
-        """Ask the confirmation before removing contacts.
-
-        @param no_sub (list[unicode]): list of contacts with no subscription
-        @param no_from (list[unicode]): list of contacts with no 'from' subscription
-        @param no_to (list[unicode]): list of contacts with no 'to' subscription
-        @return bool
-        """
-        if no_sub:
-            self.disp(
-                f"There's no subscription between profile {self.host.profile!r} and the "
-                f"following contacts:")
-            self.disp("    " + "\n    ".join(no_sub))
-        if no_from:
-            self.disp(
-                f"There's no 'from' subscription between profile {self.host.profile!r} "
-                f"and the following contacts:")
-            self.disp("    " + "\n    ".join(no_from))
-        if no_to:
-            self.disp(
-                f"There's no 'to' subscription between profile {self.host.profile!r} and "
-                f"the following contacts:")
-            self.disp("    " + "\n    ".join(no_to))
-        message = f"REMOVE them from profile {self.host.profile}'s roster"
-        while True:
-            res = await self.host.ainput(f"{message} (y/N)? ")
-            if not res or res.lower() == 'n':
-                return False
-            if res.lower() == 'y':
-                return True
-
-
-class Resync(base.CommandBase):
-
-    def __init__(self, host):
-        super(Resync, self).__init__(
-            host, 'resync', help=_('do a full resynchronisation of roster with server'))
-
-    def add_parser_options(self):
-        pass
-
-    async def start(self):
-        try:
-            await self.host.bridge.roster_resync(profile_key=self.host.profile)
-        except Exception as e:
-            self.disp(f"can't resynchronise roster: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            self.disp(_("Roster resynchronized"))
-            self.host.quit(C.EXIT_OK)
-
-
-class Roster(base.CommandBase):
-    subcommands = (Get, Set, Delete, Stats, Purge, Resync)
-
-    def __init__(self, host):
-        super(Roster, self).__init__(
-            host, 'roster', use_profile=True, help=_("Manage an entity's roster"))
--- a/sat_frontends/jp/cmd_shell.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,305 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-
-import cmd
-import sys
-import shlex
-import subprocess
-from . import base
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.jp import arg_tools
-from libervia.backend.tools.common.ansi import ANSI as A
-
-__commands__ = ["Shell"]
-INTRO = _(
-    """Welcome to {app_name} shell, the Salut à Toi shell !
-
-This enrironment helps you using several {app_name} commands with similar parameters.
-
-To quit, just enter "quit" or press C-d.
-Enter "help" or "?" to know what to do
-"""
-).format(app_name=C.APP_NAME)
-
-
-class Shell(base.CommandBase, cmd.Cmd):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "shell",
-            help=_("launch jp in shell (REPL) mode")
-        )
-        cmd.Cmd.__init__(self)
-
-    def parse_args(self, args):
-        """parse line arguments"""
-        return shlex.split(args, posix=True)
-
-    def update_path(self):
-        self._cur_parser = self.host.parser
-        self.help = ""
-        for idx, path_elt in enumerate(self.path):
-            try:
-                self._cur_parser = arg_tools.get_cmd_choices(path_elt, self._cur_parser)
-            except exceptions.NotFound:
-                self.disp(_("bad command path"), error=True)
-                self.path = self.path[:idx]
-                break
-            else:
-                self.help = self._cur_parser
-
-        self.prompt = A.color(C.A_PROMPT_PATH, "/".join(self.path)) + A.color(
-            C.A_PROMPT_SUF, "> "
-        )
-        try:
-            self.actions = list(arg_tools.get_cmd_choices(parser=self._cur_parser).keys())
-        except exceptions.NotFound:
-            self.actions = []
-
-    def add_parser_options(self):
-        pass
-
-    def format_args(self, args):
-        """format argument to be printed with quotes if needed"""
-        for arg in args:
-            if " " in arg:
-                yield arg_tools.escape(arg)
-            else:
-                yield arg
-
-    def run_cmd(self, args, external=False):
-        """run command and retur exit code
-
-        @param args[list[string]]: arguments of the command
-            must not include program name
-        @param external(bool): True if it's an external command (i.e. not jp)
-        @return (int): exit code (0 success, any other int failure)
-        """
-        # FIXME: we have to use subprocess
-        # and relaunch whole python for now
-        # because if host.quit() is called in D-Bus callback
-        # GLib quit the whole app without possibility to stop it
-        # didn't found a nice way to work around it so far
-        # Situation should be better when we'll move away from python-dbus
-        if self.verbose:
-            self.disp(
-                _("COMMAND {external}=> {args}").format(
-                    external=_("(external) ") if external else "",
-                    args=" ".join(self.format_args(args)),
-                )
-            )
-        if not external:
-            args = sys.argv[0:1] + args
-        ret_code = subprocess.call(args)
-        # XXX: below is a way to launch the command without creating a new process
-        #      may be used when a solution to the aforementioned issue is there
-        # try:
-        #     self.host._run(args)
-        # except SystemExit as e:
-        #     ret_code = e.code
-        # except Exception as e:
-        #     self.disp(A.color(C.A_FAILURE, u'command failed with an exception: {msg}'.format(msg=e)), error=True)
-        #     ret_code = 1
-        # else:
-        #     ret_code = 0
-
-        if ret_code != 0:
-            self.disp(
-                A.color(
-                    C.A_FAILURE,
-                    "command failed with an error code of {err_no}".format(
-                        err_no=ret_code
-                    ),
-                ),
-                error=True,
-            )
-        return ret_code
-
-    def default(self, args):
-        """called when no shell command is recognized
-
-        will launch the command with args on the line
-        (i.e. will launch do [args])
-        """
-        if args == "EOF":
-            self.do_quit("")
-        self.do_do(args)
-
-    def do_help(self, args):
-        """show help message"""
-        if not args:
-            self.disp(A.color(C.A_HEADER, _("Shell commands:")), end=' ')
-        super(Shell, self).do_help(args)
-        if not args:
-            self.disp(A.color(C.A_HEADER, _("Action commands:")))
-            help_list = self._cur_parser.format_help().split("\n\n")
-            print(("\n\n".join(help_list[1 if self.path else 2 :])))
-
-    # FIXME: debug crashes on exit and is not that useful,
-    #        keeping it until refactoring, may be removed entirely then
-    # def do_debug(self, args):
-    #     """launch internal debugger"""
-    #     try:
-    #         import ipdb as pdb
-    #     except ImportError:
-    #         import pdb
-    #     pdb.set_trace()
-
-    def do_verbose(self, args):
-        """show verbose mode, or (de)activate it"""
-        args = self.parse_args(args)
-        if args:
-            self.verbose = C.bool(args[0])
-        self.disp(
-            _("verbose mode is {status}").format(
-                status=_("ENABLED") if self.verbose else _("DISABLED")
-            )
-        )
-
-    def do_cmd(self, args):
-        """change command path"""
-        if args == "..":
-            self.path = self.path[:-1]
-        else:
-            if not args or args[0] == "/":
-                self.path = []
-            args = "/".join(args.split())
-            for path_elt in args.split("/"):
-                path_elt = path_elt.strip()
-                if not path_elt:
-                    continue
-                self.path.append(path_elt)
-        self.update_path()
-
-    def do_version(self, args):
-        """show current SàT/jp version"""
-        self.run_cmd(['--version'])
-
-    def do_shell(self, args):
-        """launch an external command (you can use ![command] too)"""
-        args = self.parse_args(args)
-        self.run_cmd(args, external=True)
-
-    def do_do(self, args):
-        """lauch a command"""
-        args = self.parse_args(args)
-        if (
-            self._not_default_profile
-            and not "-p" in args
-            and not "--profile" in args
-            and not "profile" in self.use
-        ):
-            # profile is not specified and we are not using the default profile
-            # so we need to add it in arguments to use current user profile
-            if self.verbose:
-                self.disp(
-                    _("arg profile={profile} (logged profile)").format(
-                        profile=self.profile
-                    )
-                )
-            use = self.use.copy()
-            use["profile"] = self.profile
-        else:
-            use = self.use
-
-        # args may be modified by use_args
-        # to remove subparsers from it
-        parser_args, use_args = arg_tools.get_use_args(
-            self.host, args, use, verbose=self.verbose, parser=self._cur_parser
-        )
-        cmd_args = self.path + parser_args + use_args
-        self.run_cmd(cmd_args)
-
-    def do_use(self, args):
-        """fix an argument"""
-        args = self.parse_args(args)
-        if not args:
-            if not self.use:
-                self.disp(_("no argument in USE"))
-            else:
-                self.disp(_("arguments in USE:"))
-                for arg, value in self.use.items():
-                    self.disp(
-                        _(
-                            A.color(
-                                C.A_SUBHEADER,
-                                arg,
-                                A.RESET,
-                                " = ",
-                                arg_tools.escape(value),
-                            )
-                        )
-                    )
-        elif len(args) != 2:
-            self.disp("bad syntax, please use:\nuse [arg] [value]", error=True)
-        else:
-            self.use[args[0]] = " ".join(args[1:])
-            if self.verbose:
-                self.disp(
-                    "set {name} = {value}".format(
-                        name=args[0], value=arg_tools.escape(args[1])
-                    )
-                )
-
-    def do_use_clear(self, args):
-        """unset one or many argument(s) in USE, or all of them if no arg is specified"""
-        args = self.parse_args(args)
-        if not args:
-            self.use.clear()
-        else:
-            for arg in args:
-                try:
-                    del self.use[arg]
-                except KeyError:
-                    self.disp(
-                        A.color(
-                            C.A_FAILURE, _("argument {name} not found").format(name=arg)
-                        ),
-                        error=True,
-                    )
-                else:
-                    if self.verbose:
-                        self.disp(_("argument {name} removed").format(name=arg))
-
-    def do_whoami(self, args):
-        """print profile currently used"""
-        self.disp(self.profile)
-
-    def do_quit(self, args):
-        """quit the shell"""
-        self.disp(_("good bye!"))
-        self.host.quit()
-
-    def do_exit(self, args):
-        """alias for quit"""
-        self.do_quit(args)
-
-    async def start(self):
-        # FIXME: "shell" is currently kept synchronous as it works well as it
-        #        and it will be refactored soon.
-        default_profile = self.host.bridge.profile_name_get(C.PROF_KEY_DEFAULT)
-        self._not_default_profile = self.profile != default_profile
-        self.path = []
-        self._cur_parser = self.host.parser
-        self.use = {}
-        self.verbose = False
-        self.update_path()
-        self.cmdloop(INTRO)
--- a/sat_frontends/jp/cmd_uri.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,81 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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 . import base
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.tools.common import uri
-
-__commands__ = ["Uri"]
-
-
-class Parse(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self,
-            host,
-            "parse",
-            use_profile=False,
-            use_output=C.OUTPUT_DICT,
-            help=_("parse URI"),
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument(
-            "uri", help=_("XMPP URI to parse")
-        )
-
-    async def start(self):
-        await self.output(uri.parse_xmpp_uri(self.args.uri))
-        self.host.quit()
-
-
-class Build(base.CommandBase):
-    def __init__(self, host):
-        base.CommandBase.__init__(
-            self, host, "build", use_profile=False, help=_("build URI")
-        )
-
-    def add_parser_options(self):
-        self.parser.add_argument("type", help=_("URI type"))
-        self.parser.add_argument("path", help=_("URI path"))
-        self.parser.add_argument(
-            "-f",
-            "--field",
-            action="append",
-            nargs=2,
-            dest="fields",
-            metavar=("KEY", "VALUE"),
-            help=_("URI fields"),
-        )
-
-    async def start(self):
-        fields = dict(self.args.fields) if self.args.fields else {}
-        self.disp(uri.build_xmpp_uri(self.args.type, path=self.args.path, **fields))
-        self.host.quit()
-
-
-class Uri(base.CommandBase):
-    subcommands = (Parse, Build)
-
-    def __init__(self, host):
-        super(Uri, self).__init__(
-            host, "uri", use_profile=False, help=_("XMPP URI parsing/generation")
-        )
--- a/sat_frontends/jp/common.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,833 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-import json
-import os
-import os.path
-import time
-import tempfile
-import asyncio
-import shlex
-import re
-from pathlib import Path
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-from libervia.backend.tools.common import regex
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import uri as xmpp_uri
-from libervia.backend.tools import config
-from configparser import NoSectionError, NoOptionError
-from collections import namedtuple
-
-# default arguments used for some known editors (editing with metadata)
-VIM_SPLIT_ARGS = "-c 'set nospr|vsplit|wincmd w|next|wincmd w'"
-EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"'
-EDITOR_ARGS_MAGIC = {
-    "vim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}",
-    "nvim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}",
-    "gvim": VIM_SPLIT_ARGS + " --nofork {content_file} {metadata_file}",
-    "emacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}",
-    "xemacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}",
-    "nano": " -F {content_file} {metadata_file}",
-}
-
-SECURE_UNLINK_MAX = 10
-SECURE_UNLINK_DIR = ".backup"
-METADATA_SUFF = "_metadata.json"
-
-
-def format_time(timestamp):
-    """Return formatted date for timestamp
-
-    @param timestamp(str,int,float): unix timestamp
-    @return (unicode): formatted date
-    """
-    fmt = "%d/%m/%Y %H:%M:%S %Z"
-    return time.strftime(fmt, time.localtime(float(timestamp)))
-
-
-def ansi_ljust(s, width):
-    """ljust method handling ANSI escape codes"""
-    cleaned = regex.ansi_remove(s)
-    return s + " " * (width - len(cleaned))
-
-
-def ansi_center(s, width):
-    """ljust method handling ANSI escape codes"""
-    cleaned = regex.ansi_remove(s)
-    diff = width - len(cleaned)
-    half = diff / 2
-    return half * " " + s + (half + diff % 2) * " "
-
-
-def ansi_rjust(s, width):
-    """ljust method handling ANSI escape codes"""
-    cleaned = regex.ansi_remove(s)
-    return " " * (width - len(cleaned)) + s
-
-
-def get_tmp_dir(sat_conf, cat_dir, sub_dir=None):
-    """Return directory used to store temporary files
-
-    @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
-    @param cat_dir(str): directory of the category (e.g. "blog")
-    @param sub_dir(str): sub directory where data need to be put
-        profile can be used here, or special directory name
-        sub_dir will be escaped to be usable in path (use regex.path_unescape to find
-        initial str)
-    @return (Path): path to the dir
-    """
-    local_dir = config.config_get(sat_conf, "", "local_dir", Exception)
-    path_elts = [local_dir, cat_dir]
-    if sub_dir is not None:
-        path_elts.append(regex.path_escape(sub_dir))
-    return Path(*path_elts)
-
-
-def parse_args(host, cmd_line, **format_kw):
-    """Parse command arguments
-
-    @param cmd_line(unicode): command line as found in sat.conf
-    @param format_kw: keywords used for formating
-    @return (list(unicode)): list of arguments to pass to subprocess function
-    """
-    try:
-        # we split the arguments and add the known fields
-        # we split arguments first to avoid escaping issues in file names
-        return [a.format(**format_kw) for a in shlex.split(cmd_line)]
-    except ValueError as e:
-        host.disp(
-            "Couldn't parse editor cmd [{cmd}]: {reason}".format(cmd=cmd_line, reason=e)
-        )
-        return []
-
-
-class BaseEdit(object):
-    """base class for editing commands
-
-    This class allows to edit file for PubSub or something else.
-    It works with temporary files in SàT local_dir, in a "cat_dir" subdir
-    """
-
-    def __init__(self, host, cat_dir, use_metadata=False):
-        """
-        @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration
-        @param cat_dir(unicode): directory to use for drafts
-            this will be a sub-directory of SàT's local_dir
-        @param use_metadata(bool): True is edition need a second file for metadata
-            most of signature change with use_metadata with an additional metadata
-            argument.
-            This is done to raise error if a command needs metadata but forget the flag,
-            and vice versa
-        """
-        self.host = host
-        self.cat_dir = cat_dir
-        self.use_metadata = use_metadata
-
-    def secure_unlink(self, path):
-        """Unlink given path after keeping it for a while
-
-        This method is used to prevent accidental deletion of a draft
-        If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX,
-        older file are deleted
-        @param path(Path, str): file to unlink
-        """
-        path = Path(path).resolve()
-        if not path.is_file:
-            raise OSError("path must link to a regular file")
-        if path.parent != get_tmp_dir(self.sat_conf, self.cat_dir):
-            self.disp(
-                f"File {path} is not in SàT temporary hierarchy, we do not remove " f"it",
-                2,
-            )
-            return
-            # we have 2 files per draft with use_metadata, so we double max
-        unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX
-        backup_dir = get_tmp_dir(self.sat_conf, self.cat_dir, SECURE_UNLINK_DIR)
-        if not os.path.exists(backup_dir):
-            os.makedirs(backup_dir)
-        filename = os.path.basename(path)
-        backup_path = os.path.join(backup_dir, filename)
-        # we move file to backup dir
-        self.host.disp(
-            "Backuping file {src} to {dst}".format(src=path, dst=backup_path),
-            1,
-        )
-        os.rename(path, backup_path)
-        # and if we exceeded the limit, we remove older file
-        backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)]
-        if len(backup_files) > unlink_max:
-            backup_files.sort(key=lambda path: os.stat(path).st_mtime)
-            for path in backup_files[: len(backup_files) - unlink_max]:
-                self.host.disp("Purging backup file {}".format(path), 2)
-                os.unlink(path)
-
-    async def run_editor(
-        self,
-        editor_args_opt,
-        content_file_path,
-        content_file_obj,
-        meta_file_path=None,
-        meta_ori=None,
-    ):
-        """Run editor to edit content and metadata
-
-        @param editor_args_opt(unicode): option in [jp] section in configuration for
-            specific args
-        @param content_file_path(str): path to the content file
-        @param content_file_obj(file): opened file instance
-        @param meta_file_path(str, Path, None): metadata file path
-            if None metadata will not be used
-        @param meta_ori(dict, None): original cotent of metadata
-            can't be used if use_metadata is False
-        """
-        if not self.use_metadata:
-            assert meta_file_path is None
-            assert meta_ori is None
-
-            # we calculate hashes to check for modifications
-        import hashlib
-
-        content_file_obj.seek(0)
-        tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest()
-        content_file_obj.close()
-
-        # we prepare arguments
-        editor = config.config_get(self.sat_conf, C.CONFIG_SECTION, "editor") or os.getenv(
-            "EDITOR", "vi"
-        )
-        try:
-            # is there custom arguments in sat.conf ?
-            editor_args = config.config_get(
-                self.sat_conf, C.CONFIG_SECTION, editor_args_opt, Exception
-            )
-        except (NoOptionError, NoSectionError):
-            # no, we check if we know the editor and have special arguments
-            if self.use_metadata:
-                editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), "")
-            else:
-                editor_args = ""
-        parse_kwargs = {"content_file": content_file_path}
-        if self.use_metadata:
-            parse_kwargs["metadata_file"] = meta_file_path
-        args = parse_args(self.host, editor_args, **parse_kwargs)
-        if not args:
-            args = [content_file_path]
-
-            # actual editing
-        editor_process = await asyncio.create_subprocess_exec(
-            editor, *[str(a) for a in args]
-        )
-        editor_exit = await editor_process.wait()
-
-        # edition will now be checked, and data will be sent if it was a success
-        if editor_exit != 0:
-            self.disp(
-                f"Editor exited with an error code, so temporary file has not be "
-                f"deleted, and item is not published.\nYou can find temporary file "
-                f"at {content_file_path}",
-                error=True,
-            )
-        else:
-            # main content
-            try:
-                with content_file_path.open("rb") as f:
-                    content = f.read()
-            except (OSError, IOError):
-                self.disp(
-                    f"Can read file at {content_file_path}, have it been deleted?\n"
-                    f"Cancelling edition",
-                    error=True,
-                )
-                self.host.quit(C.EXIT_NOT_FOUND)
-
-                # metadata
-            if self.use_metadata:
-                try:
-                    with meta_file_path.open("rb") as f:
-                        metadata = json.load(f)
-                except (OSError, IOError):
-                    self.disp(
-                        f"Can read file at {meta_file_path}, have it been deleted?\n"
-                        f"Cancelling edition",
-                        error=True,
-                    )
-                    self.host.quit(C.EXIT_NOT_FOUND)
-                except ValueError:
-                    self.disp(
-                        f"Can't parse metadata, please check it is correct JSON format. "
-                        f"Cancelling edition.\nYou can find tmp file at "
-                        f"{content_file_path} and temporary meta file at "
-                        f"{meta_file_path}.",
-                        error=True,
-                    )
-                    self.host.quit(C.EXIT_DATA_ERROR)
-
-            if self.use_metadata and not metadata.get("publish", True):
-                self.disp(
-                    f'Publication blocked by "publish" key in metadata, cancelling '
-                    f"edition.\n\ntemporary file path:\t{content_file_path}\nmetadata "
-                    f"file path:\t{meta_file_path}",
-                    error=True,
-                )
-                self.host.quit()
-
-            if len(content) == 0:
-                self.disp("Content is empty, cancelling the edition")
-                if content_file_path.parent != get_tmp_dir(self.sat_conf, self.cat_dir):
-                    self.disp(
-                        "File are not in SàT temporary hierarchy, we do not remove them",
-                        2,
-                    )
-                    self.host.quit()
-                self.disp(f"Deletion of {content_file_path}", 2)
-                os.unlink(content_file_path)
-                if self.use_metadata:
-                    self.disp(f"Deletion of {meta_file_path}".format(meta_file_path), 2)
-                    os.unlink(meta_file_path)
-                self.host.quit()
-
-                # time to re-check the hash
-            elif tmp_ori_hash == hashlib.sha1(content).digest() and (
-                not self.use_metadata or meta_ori == metadata
-            ):
-                self.disp("The content has not been modified, cancelling the edition")
-                self.host.quit()
-
-            else:
-                # we can now send the item
-                content = content.decode("utf-8-sig")  # we use utf-8-sig to avoid BOM
-                try:
-                    if self.use_metadata:
-                        await self.publish(content, metadata)
-                    else:
-                        await self.publish(content)
-                except Exception as e:
-                    if self.use_metadata:
-                        self.disp(
-                            f"Error while sending your item, the temporary files have "
-                            f"been kept at {content_file_path} and {meta_file_path}: "
-                            f"{e}",
-                            error=True,
-                        )
-                    else:
-                        self.disp(
-                            f"Error while sending your item, the temporary file has been "
-                            f"kept at {content_file_path}: {e}",
-                            error=True,
-                        )
-                    self.host.quit(1)
-
-            self.secure_unlink(content_file_path)
-            if self.use_metadata:
-                self.secure_unlink(meta_file_path)
-
-    async def publish(self, content):
-        # if metadata is needed, publish will be called with it last argument
-        raise NotImplementedError
-
-    def get_tmp_file(self):
-        """Create a temporary file
-
-        @return (tuple(file, Path)): opened (w+b) file object and file path
-        """
-        suff = "." + self.get_tmp_suff()
-        cat_dir_str = self.cat_dir
-        tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, self.profile)
-        if not tmp_dir.exists():
-            try:
-                tmp_dir.mkdir(parents=True)
-            except OSError as e:
-                self.disp(
-                    f"Can't create {tmp_dir} directory: {e}",
-                    error=True,
-                )
-                self.host.quit(1)
-        try:
-            fd, path = tempfile.mkstemp(
-                suffix=suff,
-                prefix=time.strftime(cat_dir_str + "_%Y-%m-%d_%H:%M:%S_"),
-                dir=tmp_dir,
-                text=True,
-            )
-            return os.fdopen(fd, "w+b"), Path(path)
-        except OSError as e:
-            self.disp(f"Can't create temporary file: {e}", error=True)
-            self.host.quit(1)
-
-    def get_current_file(self, profile):
-        """Get most recently edited file
-
-        @param profile(unicode): profile linked to the draft
-        @return(Path): full path of current file
-        """
-        # we guess the item currently edited by choosing
-        # the most recent file corresponding to temp file pattern
-        # in tmp_dir, excluding metadata files
-        tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, profile)
-        available = [
-            p
-            for p in tmp_dir.glob(f"{self.cat_dir}_*")
-            if not p.match(f"*{METADATA_SUFF}")
-        ]
-        if not available:
-            self.disp(
-                f"Could not find any content draft in {tmp_dir}",
-                error=True,
-            )
-            self.host.quit(1)
-        return max(available, key=lambda p: p.stat().st_mtime)
-
-    async def get_item_data(self, service, node, item):
-        """return formatted content, metadata (or not if use_metadata is false), and item id"""
-        raise NotImplementedError
-
-    def get_tmp_suff(self):
-        """return suffix used for content file"""
-        return "xml"
-
-    async def get_item_path(self):
-        """Retrieve item path (i.e. service and node) from item argument
-
-        This method is obviously only useful for edition of PubSub based features
-        """
-        service = self.args.service
-        node = self.args.node
-        item = self.args.item
-        last_item = self.args.last_item
-
-        if self.args.current:
-            # user wants to continue current draft
-            content_file_path = self.get_current_file(self.profile)
-            self.disp("Continuing edition of current draft", 2)
-            content_file_obj = content_file_path.open("r+b")
-            # we seek at the end of file in case of an item already exist
-            # this will write content of the existing item at the end of the draft.
-            # This way no data should be lost.
-            content_file_obj.seek(0, os.SEEK_END)
-        elif self.args.draft_path:
-            # there is an existing draft that we use
-            content_file_path = self.args.draft_path.expanduser()
-            content_file_obj = content_file_path.open("r+b")
-            # we seek at the end for the same reason as above
-            content_file_obj.seek(0, os.SEEK_END)
-        else:
-            # we need a temporary file
-            content_file_obj, content_file_path = self.get_tmp_file()
-
-        if item or last_item:
-            self.disp("Editing requested published item", 2)
-            try:
-                if self.use_metadata:
-                    content, metadata, item = await self.get_item_data(service, node, item)
-                else:
-                    content, item = await self.get_item_data(service, node, item)
-            except Exception as e:
-                # FIXME: ugly but we have not good may to check errors in bridge
-                if "item-not-found" in str(e):
-                    #  item doesn't exist, we create a new one with requested id
-                    metadata = None
-                    if last_item:
-                        self.disp(_("no item found at all, we create a new one"), 2)
-                    else:
-                        self.disp(
-                            _(
-                                'item "{item}" not found, we create a new item with'
-                                "this id"
-                            ).format(item=item),
-                            2,
-                        )
-                    content_file_obj.seek(0)
-                else:
-                    self.disp(f"Error while retrieving item: {e}")
-                    self.host.quit(C.EXIT_ERROR)
-            else:
-                # item exists, we write content
-                if content_file_obj.tell() != 0:
-                    # we already have a draft,
-                    # we copy item content after it and add an indicator
-                    content_file_obj.write("\n*****\n")
-                content_file_obj.write(content.encode("utf-8"))
-                content_file_obj.seek(0)
-                self.disp(_('item "{item}" found, we edit it').format(item=item), 2)
-        else:
-            self.disp("Editing a new item", 2)
-            if self.use_metadata:
-                metadata = None
-
-        if self.use_metadata:
-            return service, node, item, content_file_path, content_file_obj, metadata
-        else:
-            return service, node, item, content_file_path, content_file_obj
-
-
-class Table(object):
-    def __init__(self, host, data, headers=None, filters=None, use_buffer=False):
-        """
-        @param data(iterable[list]): table data
-            all lines must have the same number of columns
-        @param headers(iterable[unicode], None): names/titles of the columns
-            if not None, must have same number of columns as data
-        @param filters(iterable[(callable, unicode)], None): values filters
-            the callable will get 2 arguments:
-                - current column value
-                - RowData with all columns values
-            if may also only use 1 argument, which will then be current col value.
-            the callable must return a string
-            if it's unicode, it will be used with .format and must countain u'{}' which
-            will be replaced with the string.
-            if not None, must have same number of columns as data
-        @param use_buffer(bool): if True, bufferise output instead of printing it directly
-        """
-        self.host = host
-        self._buffer = [] if use_buffer else None
-        #  headers are columns names/titles, can be None
-        self.headers = headers
-        #  sizes fof columns without headers,
-        # headers may be larger
-        self.sizes = []
-        #  rows countains one list per row with columns values
-        self.rows = []
-
-        size = None
-        if headers:
-            # we use a namedtuple to make the value easily accessible from filters
-            headers_safe = [re.sub(r"[^a-zA-Z_]", "_", h) for h in headers]
-            row_cls = namedtuple("RowData", headers_safe)
-        else:
-            row_cls = tuple
-
-        for row_data in data:
-            new_row = []
-            row_data_list = list(row_data)
-            for idx, value in enumerate(row_data_list):
-                if filters is not None and filters[idx] is not None:
-                    filter_ = filters[idx]
-                    if isinstance(filter_, str):
-                        col_value = filter_.format(value)
-                    else:
-                        try:
-                            col_value = filter_(value, row_cls(*row_data_list))
-                        except TypeError:
-                            col_value = filter_(value)
-                            # we count size without ANSI code as they will change length of the
-                            # string when it's mostly style/color changes.
-                    col_size = len(regex.ansi_remove(col_value))
-                else:
-                    col_value = str(value)
-                    col_size = len(col_value)
-                new_row.append(col_value)
-                if size is None:
-                    self.sizes.append(col_size)
-                else:
-                    self.sizes[idx] = max(self.sizes[idx], col_size)
-            if size is None:
-                size = len(new_row)
-                if headers is not None and len(headers) != size:
-                    raise exceptions.DataError("headers size is not coherent with rows")
-            else:
-                if len(new_row) != size:
-                    raise exceptions.DataError("rows size is not coherent")
-            self.rows.append(new_row)
-
-        if not data and headers is not None:
-            #  the table is empty, we print headers at their lenght
-            self.sizes = [len(h) for h in headers]
-
-    @property
-    def string(self):
-        if self._buffer is None:
-            raise exceptions.InternalError("buffer must be used to get a string")
-        return "\n".join(self._buffer)
-
-    @staticmethod
-    def read_dict_values(data, keys, defaults=None):
-        if defaults is None:
-            defaults = {}
-        for key in keys:
-            try:
-                yield data[key]
-            except KeyError as e:
-                default = defaults.get(key)
-                if default is not None:
-                    yield default
-                else:
-                    raise e
-
-    @classmethod
-    def from_list_dict(
-        cls, host, data, keys=None, headers=None, filters=None, defaults=None
-    ):
-        """Create a table from a list of dictionaries
-
-        each dictionary is a row of the table, keys being columns names.
-        the whole data will be read and kept into memory, to be printed
-        @param data(list[dict[unicode, unicode]]): data to create the table from
-        @param keys(iterable[unicode], None): keys to get
-            if None, all keys will be used
-        @param headers(iterable[unicode], None): name of the columns
-            names must be in same order as keys
-        @param filters(dict[unicode, (callable,unicode)), None): filter to use on values
-            keys correspond to keys to filter, and value is the same as for Table.__init__
-        @param defaults(dict[unicode, unicode]): default value to use
-            if None, an exception will be raised if not value is found
-        """
-        if keys is None and headers is not None:
-            # FIXME: keys are not needed with OrderedDict,
-            raise exceptions.DataError("You must specify keys order to used headers")
-        if keys is None:
-            keys = list(data[0].keys())
-        if headers is None:
-            headers = keys
-        if filters is None:
-            filters = {}
-        filters = [filters.get(k) for k in keys]
-        return cls(
-            host, (cls.read_dict_values(d, keys, defaults) for d in data), headers, filters
-        )
-
-    def _headers(self, head_sep, headers, sizes, alignment="left", style=None):
-        """Render headers
-
-        @param head_sep(unicode): sequence to use as separator
-        @param alignment(unicode): how to align, can be left, center or right
-        @param style(unicode, iterable[unicode], None): ANSI escape sequences to apply
-        @param headers(list[unicode]): headers to show
-        @param sizes(list[int]): sizes of columns
-        """
-        rendered_headers = []
-        if isinstance(style, str):
-            style = [style]
-        for idx, header in enumerate(headers):
-            size = sizes[idx]
-            if alignment == "left":
-                rendered = header[:size].ljust(size)
-            elif alignment == "center":
-                rendered = header[:size].center(size)
-            elif alignment == "right":
-                rendered = header[:size].rjust(size)
-            else:
-                raise exceptions.InternalError("bad alignment argument")
-            if style:
-                args = style + [rendered]
-                rendered = A.color(*args)
-            rendered_headers.append(rendered)
-        return head_sep.join(rendered_headers)
-
-    def _disp(self, data):
-        """output data (can be either bufferised or printed)"""
-        if self._buffer is not None:
-            self._buffer.append(data)
-        else:
-            self.host.disp(data)
-
-    def display(
-        self,
-        head_alignment="left",
-        columns_alignment="left",
-        head_style=None,
-        show_header=True,
-        show_borders=True,
-        hide_cols=None,
-        col_sep=" │ ",
-        top_left="┌",
-        top="─",
-        top_sep="─┬─",
-        top_right="┐",
-        left="│",
-        right=None,
-        head_sep=None,
-        head_line="┄",
-        head_line_left="├",
-        head_line_sep="┄┼┄",
-        head_line_right="┤",
-        bottom_left="└",
-        bottom=None,
-        bottom_sep="─┴─",
-        bottom_right="┘",
-    ):
-        """Print the table
-
-        @param show_header(bool): True if header need no be shown
-        @param show_borders(bool): True if borders need no be shown
-        @param hide_cols(None, iterable(unicode)): columns which should not be displayed
-        @param head_alignment(unicode): how to align headers, can be left, center or right
-        @param columns_alignment(unicode): how to align columns, can be left, center or
-            right
-        @param col_sep(unicode): separator betweens columns
-        @param head_line(unicode): character to use to make line under head
-        @param disp(callable, None): method to use to display the table
-            None to use self.host.disp
-        """
-        if not self.sizes:
-            # the table is empty
-            return
-        col_sep_size = len(regex.ansi_remove(col_sep))
-
-        # if we have columns to hide, we remove them from headers and size
-        if not hide_cols:
-            headers = self.headers
-            sizes = self.sizes
-        else:
-            headers = list(self.headers)
-            sizes = self.sizes[:]
-            ignore_idx = [headers.index(to_hide) for to_hide in hide_cols]
-            for to_hide in hide_cols:
-                hide_idx = headers.index(to_hide)
-                del headers[hide_idx]
-                del sizes[hide_idx]
-
-        if right is None:
-            right = left
-        if top_sep is None:
-            top_sep = col_sep_size * top
-        if head_sep is None:
-            head_sep = col_sep
-        if bottom is None:
-            bottom = top
-        if bottom_sep is None:
-            bottom_sep = col_sep_size * bottom
-        if not show_borders:
-            left = right = head_line_left = head_line_right = ""
-            # top border
-        if show_borders:
-            self._disp(
-                top_left + top_sep.join([top * size for size in sizes]) + top_right
-            )
-
-            # headers
-        if show_header and self.headers is not None:
-            self._disp(
-                left
-                + self._headers(head_sep, headers, sizes, head_alignment, head_style)
-                + right
-            )
-            # header line
-            self._disp(
-                head_line_left
-                + head_line_sep.join([head_line * size for size in sizes])
-                + head_line_right
-            )
-
-            # content
-        if columns_alignment == "left":
-            alignment = lambda idx, s: ansi_ljust(s, sizes[idx])
-        elif columns_alignment == "center":
-            alignment = lambda idx, s: ansi_center(s, sizes[idx])
-        elif columns_alignment == "right":
-            alignment = lambda idx, s: ansi_rjust(s, sizes[idx])
-        else:
-            raise exceptions.InternalError("bad columns alignment argument")
-
-        for row in self.rows:
-            if hide_cols:
-                row = [v for idx, v in enumerate(row) if idx not in ignore_idx]
-            self._disp(
-                left
-                + col_sep.join([alignment(idx, c) for idx, c in enumerate(row)])
-                + right
-            )
-
-        if show_borders:
-            # bottom border
-            self._disp(
-                bottom_left
-                + bottom_sep.join([bottom * size for size in sizes])
-                + bottom_right
-            )
-            #  we return self so string can be used after display (table.display().string)
-        return self
-
-    def display_blank(self, **kwargs):
-        """Display table without visible borders"""
-        kwargs_ = {"col_sep": " ", "head_line_sep": " ", "show_borders": False}
-        kwargs_.update(kwargs)
-        return self.display(**kwargs_)
-
-
-async def fill_well_known_uri(command, path, key, meta_map=None):
-    """Look for URIs in well-known location and fill appropriate args if suitable
-
-    @param command(CommandBase): command instance
-        args of this instance will be updated with found values
-    @param path(unicode): absolute path to use as a starting point to look for URIs
-    @param key(unicode): key to look for
-    @param meta_map(dict, None): if not None, map metadata to arg name
-        key is metadata used attribute name
-        value is name to actually use, or None to ignore
-        use empty dict to only retrieve URI
-        possible keys are currently:
-            - labels
-    """
-    args = command.args
-    if args.service or args.node:
-        # we only look for URIs if a service and a node are not already specified
-        return
-
-    host = command.host
-
-    try:
-        uris_data = await host.bridge.uri_find(path, [key])
-    except Exception as e:
-        host.disp(f"can't find {key} URI: {e}", error=True)
-        host.quit(C.EXIT_BRIDGE_ERRBACK)
-
-    try:
-        uri_data = uris_data[key]
-    except KeyError:
-        host.disp(
-            _(
-                "No {key} URI specified for this project, please specify service and "
-                "node"
-            ).format(key=key),
-            error=True,
-        )
-        host.quit(C.EXIT_NOT_FOUND)
-
-    uri = uri_data["uri"]
-
-    # set extra metadata if they are specified
-    for data_key in ["labels"]:
-        new_values_json = uri_data.get(data_key)
-        if uri_data is not None:
-            if meta_map is None:
-                dest = data_key
-            else:
-                dest = meta_map.get(data_key)
-                if dest is None:
-                    continue
-
-            try:
-                values = getattr(args, data_key)
-            except AttributeError:
-                raise exceptions.InternalError(f"there is no {data_key!r} arguments")
-            else:
-                if values is None:
-                    values = []
-                values.extend(json.loads(new_values_json))
-                setattr(args, dest, values)
-
-    parsed_uri = xmpp_uri.parse_xmpp_uri(uri)
-    try:
-        args.service = parsed_uri["path"]
-        args.node = parsed_uri["node"]
-    except KeyError:
-        host.disp(_("Invalid URI found: {uri}").format(uri=uri), error=True)
-        host.quit(C.EXIT_DATA_ERROR)
--- a/sat_frontends/jp/constants.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,85 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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_frontends.quick_frontend import constants
-from libervia.backend.tools.common.ansi import ANSI as A
-
-
-class Const(constants.Const):
-
-    APP_NAME = "Libervia CLI"
-    APP_COMPONENT = "CLI"
-    APP_NAME_ALT = "jp"
-    APP_NAME_FILE = "libervia_cli"
-    CONFIG_SECTION = APP_COMPONENT.lower()
-    PLUGIN_CMD = "commands"
-    PLUGIN_OUTPUT = "outputs"
-    OUTPUT_TEXT = "text"  # blob of unicode text
-    OUTPUT_DICT = "dict"  # simple key/value dictionary
-    OUTPUT_LIST = "list"
-    OUTPUT_LIST_DICT = "list_dict"  # list of dictionaries
-    OUTPUT_DICT_DICT = "dict_dict"  # dict  of nested dictionaries
-    OUTPUT_MESS = "mess"  # messages (chat)
-    OUTPUT_COMPLEX = "complex"  # complex data (e.g. multi-level dictionary)
-    OUTPUT_XML = "xml"  # XML node (as unicode string)
-    OUTPUT_LIST_XML = "list_xml"  # list of XML nodes (as unicode strings)
-    OUTPUT_XMLUI = "xmlui"  # XMLUI as unicode string
-    OUTPUT_LIST_XMLUI = "list_xmlui"  # list of XMLUI (as unicode strings)
-    OUTPUT_TYPES = (
-        OUTPUT_TEXT,
-        OUTPUT_DICT,
-        OUTPUT_LIST,
-        OUTPUT_LIST_DICT,
-        OUTPUT_DICT_DICT,
-        OUTPUT_MESS,
-        OUTPUT_COMPLEX,
-        OUTPUT_XML,
-        OUTPUT_LIST_XML,
-        OUTPUT_XMLUI,
-        OUTPUT_LIST_XMLUI,
-    )
-    OUTPUT_NAME_SIMPLE = "simple"
-    OUTPUT_NAME_XML = "xml"
-    OUTPUT_NAME_XML_RAW = "xml-raw"
-    OUTPUT_NAME_JSON = "json"
-    OUTPUT_NAME_JSON_RAW = "json-raw"
-
-    # Pubsub options flags
-    SERVICE = "service"  # service required
-    NODE = "node"  # node required
-    ITEM = "item"  # item required
-    SINGLE_ITEM = "single_item"  # only one item is allowed
-    MULTI_ITEMS = "multi_items"  # multiple items are allowed
-    NO_MAX = "no_max"  # don't add --max option for multi items
-    CACHE = "cache"  # add cache control flag
-
-    # ANSI
-    A_HEADER = A.BOLD + A.FG_YELLOW
-    A_SUBHEADER = A.BOLD + A.FG_RED
-    # A_LEVEL_COLORS may be used to cycle on colors according to depth of data
-    A_LEVEL_COLORS = (A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
-    A_SUCCESS = A.BOLD + A.FG_GREEN
-    A_FAILURE = A.BOLD + A.FG_RED
-    A_WARNING = A.BOLD + A.FG_RED
-    #  A_PROMPT_* is for shell
-    A_PROMPT_PATH = A.BOLD + A.FG_CYAN
-    A_PROMPT_SUF = A.BOLD
-    # Files
-    A_DIRECTORY = A.BOLD + A.FG_CYAN
-    A_FILE = A.FG_WHITE
--- a/sat_frontends/jp/loops.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,145 +0,0 @@
-#!/usr/bin/env python3
-
-# jp: a SAT command line tool
-# Copyright (C) 2009-2021 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/>.
-
-import sys
-import asyncio
-import logging as log
-from libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-
-log.basicConfig(level=log.WARNING,
-                format='[%(name)s] %(message)s')
-
-USER_INTER_MSG = _("User interruption: good bye")
-
-
-class QuitException(BaseException):
-    """Quitting is requested
-
-    This is used to stop execution when host.quit() is called
-    """
-
-
-def get_jp_loop(bridge_name):
-    if 'dbus' in bridge_name:
-        import signal
-        import threading
-        from gi.repository import GLib
-
-        class JPLoop:
-
-            def run(self, jp, args, namespace):
-                signal.signal(signal.SIGINT, self._on_sigint)
-                self._glib_loop = GLib.MainLoop()
-                threading.Thread(target=self._glib_loop.run).start()
-                loop = asyncio.get_event_loop()
-                loop.run_until_complete(jp.main(args=args, namespace=namespace))
-                loop.run_forever()
-
-            def quit(self, exit_code):
-                loop = asyncio.get_event_loop()
-                loop.stop()
-                self._glib_loop.quit()
-                sys.exit(exit_code)
-
-            def call_later(self, delay, callback, *args):
-                """call a callback repeatedly
-
-                @param delay(int): delay between calls in s
-                @param callback(callable): method to call
-                    if the callback return True, the call will continue
-                    else the calls will stop
-                @param *args: args of the callbac
-                """
-                loop = asyncio.get_event_loop()
-                loop.call_later(delay, callback, *args)
-
-            def _on_sigint(self, sig_number, stack_frame):
-                """Called on keyboard interruption
-
-                Print user interruption message, set exit code and stop reactor
-                """
-                print("\r" + USER_INTER_MSG)
-                self.quit(C.EXIT_USER_CANCELLED)
-    else:
-        import signal
-        from twisted.internet import asyncioreactor
-        asyncioreactor.install()
-        from twisted.internet import reactor, defer
-
-        class JPLoop:
-
-            def __init__(self):
-                # exit code must be set when using quit, so if it's not set
-                # something got wrong and we must report it
-                self._exit_code = C.EXIT_INTERNAL_ERROR
-
-            def run(self, jp, *args):
-                self.jp = jp
-                signal.signal(signal.SIGINT, self._on_sigint)
-                defer.ensureDeferred(self._start(jp, *args))
-                try:
-                    reactor.run(installSignalHandlers=False)
-                except SystemExit as e:
-                    self._exit_code = e.code
-                sys.exit(self._exit_code)
-
-            async def _start(self, jp, *args):
-                fut = asyncio.ensure_future(jp.main(*args))
-                try:
-                    await defer.Deferred.fromFuture(fut)
-                except BaseException:
-                    import traceback
-                    traceback.print_exc()
-                    jp.quit(1)
-
-            def quit(self, exit_code):
-                self._exit_code = exit_code
-                reactor.stop()
-
-            def _timeout_cb(self, args, callback, delay):
-                try:
-                    ret = callback(*args)
-                # FIXME: temporary hack to avoid traceback when using XMLUI
-                #        to be removed once create_task is not used anymore in
-                #        xmlui_manager (i.e. once sat_frontends.tools.xmlui fully supports
-                #        async syntax)
-                except QuitException:
-                    return
-                if ret:
-                    reactor.callLater(delay, self._timeout_cb, args, callback, delay)
-
-            def call_later(self, delay, callback, *args):
-                reactor.callLater(delay, self._timeout_cb, args, callback, delay)
-
-            def _on_sigint(self, sig_number, stack_frame):
-                """Called on keyboard interruption
-
-                Print user interruption message, set exit code and stop reactor
-                """
-                print("\r" + USER_INTER_MSG)
-                self._exit_code = C.EXIT_USER_CANCELLED
-                reactor.callFromThread(reactor.stop)
-
-
-    if bridge_name == "embedded":
-        raise NotImplementedError
-        # from sat.core import sat_main
-        # sat = sat_main.SAT()
-
-    return JPLoop
--- a/sat_frontends/jp/output_std.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,127 +0,0 @@
-#! /usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-"""Standard outputs"""
-
-
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.tools import jid
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.tools.common import date_utils
-import json
-
-__outputs__ = ["Simple", "Json"]
-
-
-class Simple(object):
-    """Default outputs"""
-
-    def __init__(self, host):
-        self.host = host
-        host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_SIMPLE, self.simple_print)
-        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_SIMPLE, self.list)
-        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict)
-        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_SIMPLE, self.list_dict)
-        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict_dict)
-        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_SIMPLE, self.messages)
-        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_SIMPLE, self.simple_print)
-
-    def simple_print(self, data):
-        self.host.disp(str(data))
-
-    def list(self, data):
-        self.host.disp("\n".join(data))
-
-    def dict(self, data, indent=0, header_color=C.A_HEADER):
-        options = self.host.parse_output_options()
-        self.host.check_output_options({"no-header"}, options)
-        show_header = not "no-header" in options
-        for k, v in data.items():
-            if show_header:
-                header = A.color(header_color, k) + ": "
-            else:
-                header = ""
-
-            self.host.disp(
-                (
-                    "{indent}{header}{value}".format(
-                        indent=indent * " ", header=header, value=v
-                    )
-                )
-            )
-
-    def list_dict(self, data):
-        for idx, datum in enumerate(data):
-            if idx:
-                self.host.disp("\n")
-            self.dict(datum)
-
-    def dict_dict(self, data):
-        for key, sub_dict in data.items():
-            self.host.disp(A.color(C.A_HEADER, key))
-            self.dict(sub_dict, indent=4, header_color=C.A_SUBHEADER)
-
-    def messages(self, data):
-        # TODO: handle lang, and non chat message (normal, headline)
-        for mess_data in data:
-            (uid, timestamp, from_jid, to_jid, message, subject, mess_type,
-             extra) = mess_data
-            time_str = date_utils.date_fmt(timestamp, "auto_day",
-                                           tz_info=date_utils.TZ_LOCAL)
-            from_jid = jid.JID(from_jid)
-            if mess_type == C.MESS_TYPE_GROUPCHAT:
-                nick = from_jid.resource
-            else:
-                nick = from_jid.node
-
-            if self.host.own_jid is not None and self.host.own_jid.bare == from_jid.bare:
-                nick_color = A.BOLD + A.FG_BLUE
-            else:
-                nick_color = A.BOLD + A.FG_YELLOW
-            message = list(message.values())[0] if message else ""
-
-            self.host.disp(A.color(
-                A.FG_CYAN, '['+time_str+'] ',
-                nick_color, nick, A.RESET, A.BOLD, '> ',
-                A.RESET, message))
-
-
-class Json(object):
-    """outputs in json format"""
-
-    def __init__(self, host):
-        self.host = host
-        host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_JSON, self.dump)
-        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON_RAW, self.dump)
-        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON, self.dump_pretty)
-        host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON_RAW, self.dump)
-
-    def dump(self, data):
-        self.host.disp(json.dumps(data, default=str))
-
-    def dump_pretty(self, data):
-        self.host.disp(json.dumps(data, indent=4, default=str))
--- a/sat_frontends/jp/output_template.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,138 +0,0 @@
-#! /usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-"""Standard outputs"""
-
-
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.core.i18n import _
-from libervia.backend.core import log
-from libervia.backend.tools.common import template
-from functools import partial
-import logging
-import webbrowser
-import tempfile
-import os.path
-
-__outputs__ = ["Template"]
-TEMPLATE = "template"
-OPTIONS = {"template", "browser", "inline-css"}
-
-
-class Template(object):
-    """outputs data using SàT templates"""
-
-    def __init__(self, jp):
-        self.host = jp
-        jp.register_output(C.OUTPUT_COMPLEX, TEMPLATE, self.render)
-
-    def _front_url_tmp_dir(self, ctx, relative_url, tmp_dir):
-        """Get front URL for temporary directory"""
-        template_data = ctx['template_data']
-        return "file://" + os.path.join(tmp_dir, template_data.theme, relative_url)
-
-    def _do_render(self, template_path, css_inline, **kwargs):
-        try:
-            return self.renderer.render(template_path, css_inline=css_inline, **kwargs)
-        except template.TemplateNotFound:
-            self.host.disp(_("Can't find requested template: {template_path}")
-                .format(template_path=template_path), error=True)
-            self.host.quit(C.EXIT_NOT_FOUND)
-
-    def render(self, data):
-        """render output data using requested template
-
-        template to render the data can be either command's TEMPLATE or
-        template output_option requested by user.
-        @param data(dict): data is a dict which map from variable name to use in template
-            to the variable itself.
-            command's template_data_mapping attribute will be used if it exists to convert
-            data to a dict usable by the template.
-        """
-        # media_dir is needed for the template
-        self.host.media_dir = self.host.bridge.config_get("", "media_dir")
-        cmd = self.host.command
-        try:
-            template_path = cmd.TEMPLATE
-        except AttributeError:
-            if not "template" in cmd.args.output_opts:
-                self.host.disp(_(
-                    "no default template set for this command, you need to specify a "
-                    "template using --oo template=[path/to/template.html]"),
-                    error=True,
-                )
-                self.host.quit(C.EXIT_BAD_ARG)
-
-        options = self.host.parse_output_options()
-        self.host.check_output_options(OPTIONS, options)
-        try:
-            template_path = options["template"]
-        except KeyError:
-            # template is not specified, we use default one
-            pass
-        if template_path is None:
-            self.host.disp(_("Can't parse template, please check its syntax"),
-                           error=True)
-            self.host.quit(C.EXIT_BAD_ARG)
-
-        try:
-            mapping_cb = cmd.template_data_mapping
-        except AttributeError:
-            kwargs = data
-        else:
-            kwargs = mapping_cb(data)
-
-        css_inline = "inline-css" in options
-
-        if "browser" in options:
-            template_name = os.path.basename(template_path)
-            tmp_dir = tempfile.mkdtemp()
-            front_url_filter = partial(self._front_url_tmp_dir, tmp_dir=tmp_dir)
-            self.renderer = template.Renderer(
-                self.host, front_url_filter=front_url_filter, trusted=True)
-            rendered = self._do_render(template_path, css_inline=css_inline, **kwargs)
-            self.host.disp(_(
-                "Browser opening requested.\n"
-                "Temporary files are put in the following directory, you'll have to "
-                "delete it yourself once finished viewing: {}").format(tmp_dir))
-            tmp_file = os.path.join(tmp_dir, template_name)
-            with open(tmp_file, "w") as f:
-                f.write(rendered.encode("utf-8"))
-            theme, theme_root_path = self.renderer.get_theme_and_root(template_path)
-            if theme is None:
-                # we have an absolute path
-                webbrowser
-            static_dir = os.path.join(theme_root_path, C.TEMPLATE_STATIC_DIR)
-            if os.path.exists(static_dir):
-                # we have to copy static files in a subdirectory, to avoid file download
-                # to be blocked by same origin policy
-                import shutil
-                shutil.copytree(
-                    static_dir, os.path.join(tmp_dir, theme, C.TEMPLATE_STATIC_DIR)
-                )
-            webbrowser.open(tmp_file)
-        else:
-            # FIXME: Q&D way to disable template logging
-            #        logs are overcomplicated, and need to be reworked
-            template_logger = log.getLogger("sat.tools.common.template")
-            template_logger.log = lambda *args: None
-
-            logging.disable(logging.WARNING)
-            self.renderer = template.Renderer(self.host, trusted=True)
-            rendered = self._do_render(template_path, css_inline=css_inline, **kwargs)
-            self.host.disp(rendered)
--- a/sat_frontends/jp/output_xml.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-#! /usr/bin/env python3
-
-# Libervia CLI frontend
-# Copyright (C) 2009-2021 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/>.
-"""Standard outputs"""
-
-
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.core.i18n import _
-from lxml import etree
-from libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-import sys
-
-try:
-    import pygments
-    from pygments.lexers.html import XmlLexer
-    from pygments.formatters import TerminalFormatter
-except ImportError:
-    pygments = None
-
-
-__outputs__ = ["XML"]
-
-
-class XML(object):
-    """Outputs for XML"""
-
-    def __init__(self, host):
-        self.host = host
-        host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML, self.pretty, default=True)
-        host.register_output(
-            C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML, self.pretty_list, default=True
-        )
-        host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML_RAW, self.raw)
-        host.register_output(C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML_RAW, self.list_raw)
-
-    def colorize(self, xml):
-        if pygments is None:
-            self.host.disp(
-                _(
-                    "Pygments is not available, syntax highlighting is not possible. "
-                    "Please install if from http://pygments.org or with pip install "
-                    "pygments"
-                ),
-                error=True,
-            )
-            return xml
-        if not sys.stdout.isatty():
-            return xml
-        lexer = XmlLexer(encoding="utf-8")
-        formatter = TerminalFormatter(bg="dark")
-        return pygments.highlight(xml, lexer, formatter)
-
-    def format(self, data, pretty=True):
-        parser = etree.XMLParser(remove_blank_text=True)
-        tree = etree.fromstring(data, parser)
-        xml = etree.tostring(tree, encoding="unicode", pretty_print=pretty)
-        return self.colorize(xml)
-
-    def format_no_pretty(self, data):
-        return self.format(data, pretty=False)
-
-    def pretty(self, data):
-        self.host.disp(self.format(data))
-
-    def pretty_list(self, data, separator="\n"):
-        list_pretty = list(map(self.format, data))
-        self.host.disp(separator.join(list_pretty))
-
-    def raw(self, data):
-        self.host.disp(self.format_no_pretty(data))
-
-    def list_raw(self, data, separator="\n"):
-        list_no_pretty = list(map(self.format_no_pretty, data))
-        self.host.disp(separator.join(list_no_pretty))
--- a/sat_frontends/jp/output_xmlui.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,49 +0,0 @@
-#! /usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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/>.
-"""Standard outputs"""
-
-
-from sat_frontends.jp.constants import Const as C
-from sat_frontends.jp import xmlui_manager
-from libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-__outputs__ = ["XMLUI"]
-
-
-class XMLUI(object):
-    """Outputs for XMLUI"""
-
-    def __init__(self, host):
-        self.host = host
-        host.register_output(C.OUTPUT_XMLUI, "simple", self.xmlui, default=True)
-        host.register_output(
-            C.OUTPUT_LIST_XMLUI, "simple", self.xmlui_list, default=True
-        )
-
-    async def xmlui(self, data):
-        xmlui = xmlui_manager.create(self.host, data)
-        await xmlui.show(values_only=True, read_only=True)
-        self.host.disp("")
-
-    async def xmlui_list(self, data):
-        for d in data:
-            await self.xmlui(d)
--- a/sat_frontends/jp/xml_tools.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-#!/usr/bin/env python3
-
-
-# jp: a SàT command line tool
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-from sat_frontends.jp.constants import Const as C
-
-def etree_parse(cmd, raw_xml, reraise=False):
-    """import lxml and parse raw XML
-
-    @param cmd(CommandBase): current command instance
-    @param raw_xml(file, str): an XML bytestring, string or file-like object
-    @param reraise(bool): if True, re raise exception on parse error instead of doing a
-        parser.error (which terminate the execution)
-    @return (tuple(etree.Element, module): parsed element, etree module
-    """
-    try:
-        from lxml import etree
-    except ImportError:
-        cmd.disp(
-            'lxml module must be installed, please install it with "pip install lxml"',
-            error=True,
-        )
-        cmd.host.quit(C.EXIT_ERROR)
-    try:
-        if isinstance(raw_xml, str):
-            parser = etree.XMLParser(remove_blank_text=True)
-            element = etree.fromstring(raw_xml, parser)
-        else:
-            element = etree.parse(raw_xml).getroot()
-    except Exception as e:
-        if reraise:
-            raise e
-        cmd.parser.error(
-            _("Can't parse the payload XML in input: {msg}").format(msg=e)
-        )
-    return element, etree
-
-def get_payload(cmd, element):
-    """Retrieve payload element and exit with and error if not found
-
-    @param element(etree.Element): root element
-    @return element(etree.Element): payload element
-    """
-    if element.tag in ("item", "{http://jabber.org/protocol/pubsub}item"):
-        if len(element) > 1:
-            cmd.disp(_("<item> can only have one child element (the payload)"),
-                     error=True)
-            cmd.host.quit(C.EXIT_DATA_ERROR)
-        element = element[0]
-    return element
--- a/sat_frontends/jp/xmlui_manager.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,652 +0,0 @@
-#!/usr/bin/env python3
-
-
-# JP: a SàT frontend
-# Copyright (C) 2009-2021 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 functools import partial
-from libervia.backend.core.log import getLogger
-from sat_frontends.tools import xmlui as xmlui_base
-from sat_frontends.jp.constants import Const as C
-from libervia.backend.tools.common.ansi import ANSI as A
-from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import data_format
-
-log = getLogger(__name__)
-
-# workflow constants
-
-SUBMIT = "SUBMIT"  # submit form
-
-
-## Widgets ##
-
-
-class Base(object):
-    """Base for Widget and Container"""
-
-    type = None
-    _root = None
-
-    def __init__(self, xmlui_parent):
-        self.xmlui_parent = xmlui_parent
-        self.host = self.xmlui_parent.host
-
-    @property
-    def root(self):
-        """retrieve main XMLUI parent class"""
-        if self._root is not None:
-            return self._root
-        root = self
-        while not isinstance(root, xmlui_base.XMLUIBase):
-            root = root.xmlui_parent
-        self._root = root
-        return root
-
-    def disp(self, *args, **kwargs):
-        self.host.disp(*args, **kwargs)
-
-
-class Widget(Base):
-    category = "widget"
-    enabled = True
-
-    @property
-    def name(self):
-        return self._xmlui_name
-
-    async def show(self):
-        """display current widget
-
-        must be overriden by subclasses
-        """
-        raise NotImplementedError(self.__class__)
-
-    def verbose_name(self, elems=None, value=None):
-        """add name in color to the elements
-
-        helper method to display name which can then be used to automate commands
-        elems is only modified if verbosity is > 0
-        @param elems(list[unicode], None): elements to display
-            None to display name directly
-        @param value(unicode, None): value to show
-            use self.name if None
-        """
-        if value is None:
-            value = self.name
-        if self.host.verbosity:
-            to_disp = [
-                A.FG_MAGENTA,
-                " " if elems else "",
-                "({})".format(value),
-                A.RESET,
-            ]
-            if elems is None:
-                self.host.disp(A.color(*to_disp))
-            else:
-                elems.extend(to_disp)
-
-
-class ValueWidget(Widget):
-    def __init__(self, xmlui_parent, value):
-        super(ValueWidget, self).__init__(xmlui_parent)
-        self.value = value
-
-    @property
-    def values(self):
-        return [self.value]
-
-
-class InputWidget(ValueWidget):
-    def __init__(self, xmlui_parent, value, read_only=False):
-        super(InputWidget, self).__init__(xmlui_parent, value)
-        self.read_only = read_only
-
-    def _xmlui_get_value(self):
-        return self.value
-
-
-class OptionsWidget(Widget):
-    def __init__(self, xmlui_parent, options, selected, style):
-        super(OptionsWidget, self).__init__(xmlui_parent)
-        self.options = options
-        self.selected = selected
-        self.style = style
-
-    @property
-    def values(self):
-        return self.selected
-
-    @values.setter
-    def values(self, values):
-        self.selected = values
-
-    @property
-    def value(self):
-        return self.selected[0]
-
-    @value.setter
-    def value(self, value):
-        self.selected = [value]
-
-    def _xmlui_select_value(self, value):
-        self.value = value
-
-    def _xmlui_select_values(self, values):
-        self.values = values
-
-    def _xmlui_get_selected_values(self):
-        return self.values
-
-    @property
-    def labels(self):
-        """return only labels from self.items"""
-        for value, label in self.items:
-            yield label
-
-    @property
-    def items(self):
-        """return suitable items, according to style"""
-        no_select = self.no_select
-        for value, label in self.options:
-            if no_select or value in self.selected:
-                yield value, label
-
-    @property
-    def inline(self):
-        return "inline" in self.style
-
-    @property
-    def no_select(self):
-        return "noselect" in self.style
-
-
-class EmptyWidget(xmlui_base.EmptyWidget, Widget):
-    def __init__(self, xmlui_parent):
-        Widget.__init__(self, xmlui_parent)
-
-    async def show(self):
-        self.host.disp("")
-
-
-class TextWidget(xmlui_base.TextWidget, ValueWidget):
-    type = "text"
-
-    async def show(self):
-        self.host.disp(self.value)
-
-
-class LabelWidget(xmlui_base.LabelWidget, ValueWidget):
-    type = "label"
-
-    @property
-    def for_name(self):
-        try:
-            return self._xmlui_for_name
-        except AttributeError:
-            return None
-
-    async def show(self, end="\n", ansi=""):
-        """show label
-
-        @param end(str): same as for [JP.disp]
-        @param ansi(unicode): ansi escape code to print before label
-        """
-        self.disp(A.color(ansi, self.value), end=end)
-
-
-class JidWidget(xmlui_base.JidWidget, TextWidget):
-    type = "jid"
-
-
-class StringWidget(xmlui_base.StringWidget, InputWidget):
-    type = "string"
-
-    async def show(self):
-        if self.read_only or self.root.read_only:
-            self.disp(self.value)
-        else:
-            elems = []
-            self.verbose_name(elems)
-            if self.value:
-                elems.append(_("(enter: {value})").format(value=self.value))
-            elems.extend([C.A_HEADER, "> "])
-            value = await self.host.ainput(A.color(*elems))
-            if value:
-                #  TODO: empty value should be possible
-                #       an escape key should be used for default instead of enter with empty value
-                self.value = value
-
-
-class JidInputWidget(xmlui_base.JidInputWidget, StringWidget):
-    type = "jid_input"
-
-
-class PasswordWidget(xmlui_base.PasswordWidget, StringWidget):
-    type = "password"
-
-
-class TextBoxWidget(xmlui_base.TextWidget, StringWidget):
-    type = "textbox"
-    # TODO: use a more advanced input method
-
-    async def show(self):
-        self.verbose_name()
-        if self.read_only or self.root.read_only:
-            self.disp(self.value)
-        else:
-            if self.value:
-                self.disp(
-                    A.color(C.A_HEADER, "↓ current value ↓\n", A.FG_CYAN, self.value, "")
-                )
-
-            values = []
-            while True:
-                try:
-                    if not values:
-                        line = await self.host.ainput(
-                            A.color(C.A_HEADER, "[Ctrl-D to finish]> ")
-                        )
-                    else:
-                        line = await self.host.ainput()
-                    values.append(line)
-                except EOFError:
-                    break
-
-            self.value = "\n".join(values).rstrip()
-
-
-class XHTMLBoxWidget(xmlui_base.XHTMLBoxWidget, StringWidget):
-    type = "xhtmlbox"
-
-    async def show(self):
-        # FIXME: we use bridge in a blocking way as permitted by python-dbus
-        #        this only for now to make it simpler, it must be refactored
-        #        to use async when jp will be fully async (expected for 0.8)
-        self.value = await self.host.bridge.syntax_convert(
-            self.value, C.SYNTAX_XHTML, "markdown", False, self.host.profile
-        )
-        await super(XHTMLBoxWidget, self).show()
-
-
-class ListWidget(xmlui_base.ListWidget, OptionsWidget):
-    type = "list"
-    # TODO: handle flags, notably multi
-
-    async def show(self):
-        if self.root.values_only:
-            for value in self.values:
-                self.disp(self.value)
-                return
-        if not self.options:
-            return
-
-            # list display
-        self.verbose_name()
-
-        for idx, (value, label) in enumerate(self.options):
-            elems = []
-            if not self.root.read_only:
-                elems.extend([C.A_SUBHEADER, str(idx), A.RESET, ": "])
-            elems.append(label)
-            self.verbose_name(elems, value)
-            self.disp(A.color(*elems))
-
-        if self.root.read_only:
-            return
-
-        if len(self.options) == 1:
-            # we have only one option, no need to ask
-            self.value = self.options[0][0]
-            return
-
-            #  we ask use to choose an option
-        choice = None
-        limit_max = len(self.options) - 1
-        while choice is None or choice < 0 or choice > limit_max:
-            choice = await self.host.ainput(
-                A.color(
-                    C.A_HEADER,
-                    _("your choice (0-{limit_max}): ").format(limit_max=limit_max),
-                )
-            )
-            try:
-                choice = int(choice)
-            except ValueError:
-                choice = None
-        self.value = self.options[choice][0]
-        self.disp("")
-
-
-class BoolWidget(xmlui_base.BoolWidget, InputWidget):
-    type = "bool"
-
-    async def show(self):
-        disp_true = A.color(A.FG_GREEN, "TRUE")
-        disp_false = A.color(A.FG_RED, "FALSE")
-        if self.read_only or self.root.read_only:
-            self.disp(disp_true if self.value else disp_false)
-        else:
-            self.disp(
-                A.color(
-                    C.A_HEADER, "0: ", disp_false, A.RESET, " *" if not self.value else ""
-                )
-            )
-            self.disp(
-                A.color(C.A_HEADER, "1: ", disp_true, A.RESET, " *" if self.value else "")
-            )
-            choice = None
-            while choice not in ("0", "1"):
-                elems = [C.A_HEADER, _("your choice (0,1): ")]
-                self.verbose_name(elems)
-                choice = await self.host.ainput(A.color(*elems))
-            self.value = bool(int(choice))
-            self.disp("")
-
-    def _xmlui_get_value(self):
-        return C.bool_const(self.value)
-
-        ## Containers ##
-
-
-class Container(Base):
-    category = "container"
-
-    def __init__(self, xmlui_parent):
-        super(Container, self).__init__(xmlui_parent)
-        self.children = []
-
-    def __iter__(self):
-        return iter(self.children)
-
-    def _xmlui_append(self, widget):
-        self.children.append(widget)
-
-    def _xmlui_remove(self, widget):
-        self.children.remove(widget)
-
-    async def show(self):
-        for child in self.children:
-            await child.show()
-
-
-class VerticalContainer(xmlui_base.VerticalContainer, Container):
-    type = "vertical"
-
-
-class PairsContainer(xmlui_base.PairsContainer, Container):
-    type = "pairs"
-
-
-class LabelContainer(xmlui_base.PairsContainer, Container):
-    type = "label"
-
-    async def show(self):
-        for child in self.children:
-            end = "\n"
-            # we check linked widget type
-            # to see if we want the label on the same line or not
-            if child.type == "label":
-                for_name = child.for_name
-                if for_name:
-                    for_widget = self.root.widgets[for_name]
-                    wid_type = for_widget.type
-                    if self.root.values_only or wid_type in (
-                        "text",
-                        "string",
-                        "jid_input",
-                    ):
-                        end = " "
-                    elif wid_type == "bool" and for_widget.read_only:
-                        end = " "
-                await child.show(end=end, ansi=A.FG_CYAN)
-            else:
-                await child.show()
-
-                ## Dialogs ##
-
-
-class Dialog(object):
-    def __init__(self, xmlui_parent):
-        self.xmlui_parent = xmlui_parent
-        self.host = self.xmlui_parent.host
-
-    def disp(self, *args, **kwargs):
-        self.host.disp(*args, **kwargs)
-
-    async def show(self):
-        """display current dialog
-
-        must be overriden by subclasses
-        """
-        raise NotImplementedError(self.__class__)
-
-
-class MessageDialog(xmlui_base.MessageDialog, Dialog):
-    def __init__(self, xmlui_parent, title, message, level):
-        Dialog.__init__(self, xmlui_parent)
-        xmlui_base.MessageDialog.__init__(self, xmlui_parent)
-        self.title, self.message, self.level = title, message, level
-
-    async def show(self):
-        # TODO: handle level
-        if self.title:
-            self.disp(A.color(C.A_HEADER, self.title))
-        self.disp(self.message)
-
-
-class NoteDialog(xmlui_base.NoteDialog, Dialog):
-    def __init__(self, xmlui_parent, title, message, level):
-        Dialog.__init__(self, xmlui_parent)
-        xmlui_base.NoteDialog.__init__(self, xmlui_parent)
-        self.title, self.message, self.level = title, message, level
-
-    async def show(self):
-        # TODO: handle title
-        error = self.level in (C.XMLUI_DATA_LVL_WARNING, C.XMLUI_DATA_LVL_ERROR)
-        if self.level == C.XMLUI_DATA_LVL_WARNING:
-            msg = A.color(C.A_WARNING, self.message)
-        elif self.level == C.XMLUI_DATA_LVL_ERROR:
-            msg = A.color(C.A_FAILURE, self.message)
-        else:
-            msg = self.message
-        self.disp(msg, error=error)
-
-
-class ConfirmDialog(xmlui_base.ConfirmDialog, Dialog):
-    def __init__(self, xmlui_parent, title, message, level, buttons_set):
-        Dialog.__init__(self, xmlui_parent)
-        xmlui_base.ConfirmDialog.__init__(self, xmlui_parent)
-        self.title, self.message, self.level, self.buttons_set = (
-            title,
-            message,
-            level,
-            buttons_set,
-        )
-
-    async def show(self):
-        # TODO: handle buttons_set and level
-        self.disp(self.message)
-        if self.title:
-            self.disp(A.color(C.A_HEADER, self.title))
-        input_ = None
-        while input_ not in ("y", "n"):
-            input_ = await self.host.ainput(f"{self.message} (y/n)? ")
-            input_ = input_.lower()
-        if input_ == "y":
-            self._xmlui_validated()
-        else:
-            self._xmlui_cancelled()
-
-            ## Factory ##
-
-
-class WidgetFactory(object):
-    def __getattr__(self, attr):
-        if attr.startswith("create"):
-            cls = globals()[attr[6:]]
-            return cls
-
-
-class XMLUIPanel(xmlui_base.AIOXMLUIPanel):
-    widget_factory = WidgetFactory()
-    _actions = 0  # use to keep track of bridge's action_launch calls
-    read_only = False
-    values_only = False
-    workflow = None
-    _submit_cb = None
-
-    def __init__(
-        self,
-        host,
-        parsed_dom,
-        title=None,
-        flags=None,
-        callback=None,
-        ignore=None,
-        whitelist=None,
-        profile=None,
-    ):
-        xmlui_base.XMLUIPanel.__init__(
-            self,
-            host,
-            parsed_dom,
-            title=title,
-            flags=flags,
-            ignore=ignore,
-            whitelist=whitelist,
-            profile=host.profile,
-        )
-        self.submitted = False
-
-    @property
-    def command(self):
-        return self.host.command
-
-    def disp(self, *args, **kwargs):
-        self.host.disp(*args, **kwargs)
-
-    async def show(self, workflow=None, read_only=False, values_only=False):
-        """display the panel
-
-        @param workflow(list, None): command to execute if not None
-            put here for convenience, the main workflow is the class attribute
-            (because workflow can continue in subclasses)
-            command are a list of consts or lists:
-                - SUBMIT is the only constant so far, it submits the XMLUI
-                - list must contain widget name/widget value to fill
-        @param read_only(bool): if True, don't request values
-        @param values_only(bool): if True, only show select values (imply read_only)
-        """
-        self.read_only = read_only
-        self.values_only = values_only
-        if self.values_only:
-            self.read_only = True
-        if workflow:
-            XMLUIPanel.workflow = workflow
-        if XMLUIPanel.workflow:
-            await self.run_workflow()
-        else:
-            await self.main_cont.show()
-
-    async def run_workflow(self):
-        """loop into workflow commands and execute commands
-
-        SUBMIT will interrupt workflow (which will be continue on callback)
-        @param workflow(list): same as [show]
-        """
-        workflow = XMLUIPanel.workflow
-        while True:
-            try:
-                cmd = workflow.pop(0)
-            except IndexError:
-                break
-            if cmd == SUBMIT:
-                await self.on_form_submitted()
-                self.submit_id = None  # avoid double submit
-                return
-            elif isinstance(cmd, list):
-                name, value = cmd
-                widget = self.widgets[name]
-                if widget.type == "bool":
-                    value = C.bool(value)
-                widget.value = value
-        await self.show()
-
-    async def submit_form(self, callback=None):
-        XMLUIPanel._submit_cb = callback
-        await self.on_form_submitted()
-
-    async def on_form_submitted(self, ignore=None):
-        # self.submitted is a Q&D workaround to avoid
-        # double submit when a workflow is set
-        if self.submitted:
-            return
-        self.submitted = True
-        await super(XMLUIPanel, self).on_form_submitted(ignore)
-
-    def _xmlui_close(self):
-        pass
-
-    async def _launch_action_cb(self, data):
-        XMLUIPanel._actions -= 1
-        assert XMLUIPanel._actions >= 0
-        if "xmlui" in data:
-            xmlui_raw = data["xmlui"]
-            xmlui = create(self.host, xmlui_raw)
-            await xmlui.show()
-            if xmlui.submit_id:
-                await xmlui.on_form_submitted()
-                # TODO: handle data other than XMLUI
-        if not XMLUIPanel._actions:
-            if self._submit_cb is None:
-                self.host.quit()
-            else:
-                self._submit_cb()
-
-    async def _xmlui_launch_action(self, action_id, data):
-        XMLUIPanel._actions += 1
-        try:
-            data = data_format.deserialise(
-                await self.host.bridge.action_launch(
-                    action_id,
-                    data_format.serialise(data),
-                    self.profile,
-                )
-            )
-        except Exception as e:
-            self.disp(f"can't launch XMLUI action: {e}", error=True)
-            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
-        else:
-            await self._launch_action_cb(data)
-
-
-class XMLUIDialog(xmlui_base.XMLUIDialog):
-    type = "dialog"
-    dialog_factory = WidgetFactory()
-    read_only = False
-
-    async def show(self, __=None):
-        await self.dlg.show()
-
-    def _xmlui_close(self):
-        pass
-
-
-create = partial(
-    xmlui_base.create,
-    class_map={xmlui_base.CLASS_PANEL: XMLUIPanel, xmlui_base.CLASS_DIALOG: XMLUIDialog},
-)
--- a/sat_frontends/primitivus/base.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,863 +0,0 @@
-#!/usr/bin/env python3
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-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 libervia.backend.core.i18n import _, D_
-from sat_frontends.primitivus.constants import Const as C
-from libervia.backend.core import log_config
-log_config.sat_configure(C.LOG_BACKEND_STANDARD, C)
-from libervia.backend.core import log as logging
-log = logging.getLogger(__name__)
-from libervia.backend.tools import config as sat_config
-import urwid
-from urwid.util import is_wide_char
-from urwid_satext import sat_widgets
-from sat_frontends.quick_frontend.quick_app import QuickApp
-from sat_frontends.quick_frontend import quick_utils
-from sat_frontends.quick_frontend import quick_chat
-from sat_frontends.primitivus.profile_manager import ProfileManager
-from sat_frontends.primitivus.contact_list import ContactList
-from sat_frontends.primitivus.chat import Chat
-from sat_frontends.primitivus import xmlui
-from sat_frontends.primitivus.progress import Progress
-from sat_frontends.primitivus.notify import Notify
-from sat_frontends.primitivus.keys import action_key_map as a_key
-from sat_frontends.primitivus import config
-from sat_frontends.tools.misc import InputHistory
-from libervia.backend.tools.common import dynamic_import
-from sat_frontends.tools import jid
-import signal
-import sys
-## bridge handling
-# we get bridge name from conf and initialise the right class accordingly
-main_config = sat_config.parse_main_conf()
-bridge_name = sat_config.config_get(main_config, '', 'bridge', 'dbus')
-if 'dbus' not in bridge_name:
-    print(u"only D-Bus bridge is currently supported")
-    sys.exit(3)
-
-
-class EditBar(sat_widgets.ModalEdit):
-    """
-    The modal edit bar where you would enter messages and commands.
-    """
-
-    def __init__(self, host):
-        modes = {None: (C.MODE_NORMAL, u''),
-                 a_key['MODE_INSERTION']: (C.MODE_INSERTION, u'> '),
-                 a_key['MODE_COMMAND']: (C.MODE_COMMAND, u':')} #XXX: captions *MUST* be unicode
-        super(EditBar, self).__init__(modes)
-        self.host = host
-        self.set_completion_method(self._text_completion)
-        urwid.connect_signal(self, 'click', self.on_text_entered)
-
-    def _text_completion(self, text, completion_data, mode):
-        if mode == C.MODE_INSERTION:
-            if self.host.selected_widget is not None:
-                try:
-                    completion = self.host.selected_widget.completion
-                except AttributeError:
-                    return text
-                else:
-                    return completion(text, completion_data)
-        else:
-            return text
-
-    def on_text_entered(self, editBar):
-        """Called when text is entered in the main edit bar"""
-        if self.mode == C.MODE_INSERTION:
-            if isinstance(self.host.selected_widget, quick_chat.QuickChat):
-                chat_widget = self.host.selected_widget
-                self.host.message_send(
-                    chat_widget.target,
-                    {'': editBar.get_edit_text()}, # TODO: handle language
-                    mess_type = C.MESS_TYPE_GROUPCHAT if chat_widget.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, # TODO: put this in QuickChat
-                    errback=lambda failure: self.host.show_dialog(_("Error while sending message ({})").format(failure), type="error"),
-                    profile_key=chat_widget.profile
-                    )
-                editBar.set_edit_text('')
-        elif self.mode == C.MODE_COMMAND:
-            self.command_handler()
-
-    def command_handler(self):
-        #TODO: separate class with auto documentation (with introspection)
-        #      and completion method
-        tokens = self.get_edit_text().split(' ')
-        command, args = tokens[0], tokens[1:]
-        if command == 'quit':
-            self.host.on_exit()
-            raise urwid.ExitMainLoop()
-        elif command == 'messages':
-            wid = sat_widgets.GenericList(logging.memory_get())
-            self.host.select_widget(wid)
-        # FIXME: reactivate the command
-        # elif command == 'presence':
-        #     values = [value for value in commonConst.PRESENCE.keys()]
-        #     values = [value if value else 'online' for value in values]  # the empty value actually means 'online'
-        #     if args and args[0] in values:
-        #         presence = '' if args[0] == 'online' else args[0]
-        #         self.host.status_bar.on_change(user_data=sat_widgets.ClickableText(commonConst.PRESENCE[presence]))
-        #     else:
-        #         self.host.status_bar.on_presence_click()
-        # elif command == 'status':
-        #     if args:
-        #         self.host.status_bar.on_change(user_data=sat_widgets.AdvancedEdit(args[0]))
-        #     else:
-        #         self.host.status_bar.on_status_click()
-        elif command == 'history':
-            widget = self.host.selected_widget
-            if isinstance(widget, quick_chat.QuickChat):
-                try:
-                    limit = int(args[0])
-                except (IndexError, ValueError):
-                    limit = 50
-                widget.update_history(size=limit, profile=widget.profile)
-        elif command == 'search':
-            widget = self.host.selected_widget
-            if isinstance(widget, quick_chat.QuickChat):
-                pattern = " ".join(args)
-                if not pattern:
-                    self.host.notif_bar.add_message(D_("Please specify the globbing pattern to search for"))
-                else:
-                    widget.update_history(size=C.HISTORY_LIMIT_NONE, filters={'search': pattern}, profile=widget.profile)
-        elif command == 'filter':
-            # FIXME: filter is now only for current widget,
-            #        need to be able to set it globally or per widget
-            widget = self.host.selected_widget
-            # FIXME: Q&D way, need to be more generic
-            if isinstance(widget, quick_chat.QuickChat):
-                widget.set_filter(args)
-        elif command in ('topic', 'suject', 'title'):
-            try:
-                new_title = args[0].strip()
-            except IndexError:
-                new_title = None
-            widget = self.host.selected_widget
-            if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP:
-                widget.on_subject_dialog(new_title)
-        else:
-            return
-        self.set_edit_text('')
-
-    def _history_cb(self, text):
-        self.set_edit_text(text)
-        self.set_edit_pos(len(text))
-
-    def keypress(self, size, key):
-        """Callback when a key is pressed. Send "composing" states
-        and move the index of the temporary history stack."""
-        if key == a_key['MODAL_ESCAPE']:
-            # first save the text to the current mode, then change to NORMAL
-            self.host._update_input_history(self.get_edit_text(), mode=self.mode)
-            self.host._update_input_history(mode=C.MODE_NORMAL)
-        if self._mode == C.MODE_NORMAL and key in self._modes:
-            self.host._update_input_history(mode=self._modes[key][0])
-        if key == a_key['HISTORY_PREV']:
-            self.host._update_input_history(self.get_edit_text(), -1, self._history_cb, self.mode)
-            return
-        elif key == a_key['HISTORY_NEXT']:
-            self.host._update_input_history(self.get_edit_text(), +1, self._history_cb, self.mode)
-            return
-        elif key == a_key['EDIT_ENTER']:
-            self.host._update_input_history(self.get_edit_text(), mode=self.mode)
-        else:
-            if (self._mode == C.MODE_INSERTION
-                and isinstance(self.host.selected_widget, quick_chat.QuickChat)
-                and key not in sat_widgets.FOCUS_KEYS
-                and key not in (a_key['HISTORY_PREV'], a_key['HISTORY_NEXT'])
-                and self.host.sync):
-                self.host.bridge.chat_state_composing(self.host.selected_widget.target, self.host.selected_widget.profile)
-
-        return super(EditBar, self).keypress(size, key)
-
-
-class PrimitivusTopWidget(sat_widgets.FocusPile):
-    """Top most widget used in Primitivus"""
-    _focus_inversed = True
-    positions = ('menu', 'body', 'notif_bar', 'edit_bar')
-    can_hide = ('menu', 'notif_bar')
-
-    def __init__(self, body, menu, notif_bar, edit_bar):
-        self._body = body
-        self._menu = menu
-        self._notif_bar = notif_bar
-        self._edit_bar = edit_bar
-        self._hidden = {'notif_bar'}
-        self._focus_extra = False
-        super(PrimitivusTopWidget, self).__init__([('pack', self._menu), self._body, ('pack', self._edit_bar)])
-        for position in self.positions:
-            setattr(self,
-                    position,
-                    property(lambda: self, self.widget_get(position=position),
-                             lambda pos, new_wid: self.widget_set(new_wid, position=pos))
-                   )
-        self.focus_position = len(self.contents)-1
-
-    def get_visible_positions(self, keep=None):
-        """Return positions that are not hidden in the right order
-
-        @param keep: if not None, this position will be keep in the right order, even if it's hidden
-                    (can be useful to find its index)
-        @return (list): list of visible positions
-        """
-        return [pos for pos in self.positions if (keep and pos == keep) or pos not in self._hidden]
-
-    def keypress(self, size, key):
-        """Manage FOCUS keys that focus directly a main part (one of self.positions)
-
-        To avoid key conflicts, a combinaison must be made with FOCUS_EXTRA then an other key
-        """
-        if key == a_key['FOCUS_EXTRA']:
-            self._focus_extra = True
-            return
-        if self._focus_extra:
-            self._focus_extra = False
-            if key in ('m', '1'):
-                focus = 'menu'
-            elif key in ('b', '2'):
-                focus = 'body'
-            elif key in ('n', '3'):
-                focus = 'notif_bar'
-            elif key in ('e', '4'):
-                focus = 'edit_bar'
-            else:
-                return super(PrimitivusTopWidget, self).keypress(size, key)
-
-            if focus in self._hidden:
-                return
-
-            self.focus_position = self.get_visible_positions().index(focus)
-            return
-
-        return super(PrimitivusTopWidget, self).keypress(size, key)
-
-    def widget_get(self,  position):
-        if not position in self.positions:
-            raise ValueError("Unknown position {}".format(position))
-        return getattr(self, "_{}".format(position))
-
-    def widget_set(self,  widget, position):
-        if not position in self.positions:
-            raise ValueError("Unknown position {}".format(position))
-        return setattr(self, "_{}".format(position), widget)
-
-    def hide_switch(self, position):
-        if not position in self.can_hide:
-            raise ValueError("Can't switch position {}".format(position))
-        hide = not position in self._hidden
-        widget = self.widget_get(position)
-        idx = self.get_visible_positions(position).index(position)
-        if hide:
-            del self.contents[idx]
-            self._hidden.add(position)
-        else:
-            self.contents.insert(idx, (widget, ('pack', None)))
-            self._hidden.remove(position)
-
-    def show(self, position):
-        if position in self._hidden:
-            self.hide_switch(position)
-
-    def hide(self, position):
-        if not position in self._hidden:
-            self.hide_switch(position)
-
-
-class PrimitivusApp(QuickApp, InputHistory):
-    MB_HANDLER = False
-    AVATARS_HANDLER = False
-
-    def __init__(self):
-        bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge')
-        if bridge_module is None:
-            log.error(u"Can't import {} bridge".format(bridge_name))
-            sys.exit(3)
-        else:
-            log.debug(u"Loading {} bridge".format(bridge_name))
-        QuickApp.__init__(self, bridge_factory=bridge_module.bridge, xmlui=xmlui, check_options=quick_utils.check_options, connect_bridge=False)
-        ## main loop setup ##
-        event_loop = urwid.GLibEventLoop if 'dbus' in bridge_name else urwid.TwistedEventLoop
-        self.loop = urwid.MainLoop(urwid.SolidFill(), C.PALETTE, event_loop=event_loop(), input_filter=self.input_filter, unhandled_input=self.key_handler)
-
-    @classmethod
-    def run(cls):
-        cls().start()
-
-    def on_bridge_connected(self):
-
-        ##misc setup##
-        self._visible_widgets = set()
-        self.notif_bar = sat_widgets.NotificationBar()
-        urwid.connect_signal(self.notif_bar, 'change', self.on_notification)
-
-        self.progress_wid = self.widgets.get_or_create_widget(Progress, None, on_new_widget=None)
-        urwid.connect_signal(self.notif_bar.progress, 'click', lambda x: self.select_widget(self.progress_wid))
-        self.__saved_overlay = None
-
-        self.x_notify = Notify()
-
-        # we already manage exit with a_key['APP_QUIT'], so we don't want C-c
-        signal.signal(signal.SIGINT, signal.SIG_IGN)
-        sat_conf = sat_config.parse_main_conf()
-        self._bracketed_paste = C.bool(
-            sat_config.config_get(sat_conf, C.CONFIG_SECTION, 'bracketed_paste', 'false')
-        )
-        if self._bracketed_paste:
-            log.debug("setting bracketed paste mode as requested")
-            sys.stdout.write("\033[?2004h")
-            self._bracketed_mode_set = True
-
-        self.loop.widget = self.main_widget = ProfileManager(self)
-        self.post_init()
-
-    @property
-    def visible_widgets(self):
-        return self._visible_widgets
-
-    @property
-    def mode(self):
-        return self.editBar.mode
-
-    @mode.setter
-    def mode(self, value):
-        self.editBar.mode = value
-
-    def mode_hint(self, value):
-        """Change mode if make sens (i.e.: if there is nothing in the editBar)"""
-        if not self.editBar.get_edit_text():
-            self.mode = value
-
-    def debug(self):
-        """convenient method to reset screen and launch (i)p(u)db"""
-        log.info('Entered debug mode')
-        try:
-            import pudb
-            pudb.set_trace()
-        except ImportError:
-            import os
-            os.system('reset')
-            try:
-                import ipdb
-                ipdb.set_trace()
-            except ImportError:
-                import pdb
-                pdb.set_trace()
-
-    def redraw(self):
-        """redraw the screen"""
-        try:
-            self.loop.draw_screen()
-        except AttributeError:
-            pass
-
-    def start(self):
-        self.connect_bridge()
-        self.loop.run()
-
-    def post_init(self):
-        try:
-            config.apply_config(self)
-        except Exception as e:
-            log.error(u"configuration error: {}".format(e))
-            popup = self.alert(_(u"Configuration Error"), _(u"Something went wrong while reading the configuration, please check :messages"))
-            if self.options.profile:
-                self._early_popup = popup
-            else:
-                self.show_pop_up(popup)
-        super(PrimitivusApp, self).post_init(self.main_widget)
-
-    def keys_to_text(self, keys):
-        """Generator return normal text from urwid keys"""
-        for k in keys:
-            if k == 'tab':
-                yield u'\t'
-            elif k == 'enter':
-                yield u'\n'
-            elif is_wide_char(k,0) or (len(k)==1 and ord(k) >= 32):
-                yield k
-
-    def input_filter(self, input_, raw):
-        if self.__saved_overlay and input_ != a_key['OVERLAY_HIDE']:
-            return
-
-        ## paste detection/handling
-        if (len(input_) > 1 and                  # XXX: it may be needed to increase this value if buffer
-            not isinstance(input_[0], tuple) and #      or other things result in several chars at once
-            not 'window resize' in input_):      #      (e.g. using Primitivus through ssh). Need some testing
-                                                 #      and experience to adjust value.
-            if input_[0] == 'begin paste' and not self._bracketed_paste:
-                log.info(u"Bracketed paste mode detected")
-                self._bracketed_paste = True
-
-            if self._bracketed_paste:
-                # after this block, extra will contain non pasted keys
-                # and input_ will contain pasted keys
-                try:
-                    begin_idx = input_.index('begin paste')
-                except ValueError:
-                    # this is not a paste, maybe we have something buffering
-                    # or bracketed mode is set in conf but not enabled in term
-                    extra = input_
-                    input_ = []
-                else:
-                    try:
-                        end_idx = input_.index('end paste')
-                    except ValueError:
-                        log.warning(u"missing end paste sequence, discarding paste")
-                        extra = input_[:begin_idx]
-                        del input_[begin_idx:]
-                    else:
-                        extra = input_[:begin_idx] + input_[end_idx+1:]
-                        input_ = input_[begin_idx+1:end_idx]
-            else:
-                extra = None
-
-            log.debug(u"Paste detected (len {})".format(len(input_)))
-            try:
-                edit_bar = self.editBar
-            except AttributeError:
-                log.warning(u"Paste treated as normal text: there is no edit bar yet")
-                if extra is None:
-                    extra = []
-                extra.extend(input_)
-            else:
-                if self.main_widget.focus == edit_bar:
-                    # XXX: if a paste is detected, we append it directly to the edit bar text
-                    #      so the user can check it and press [enter] if it's OK
-                    buf_paste = u''.join(self.keys_to_text(input_))
-                    pos = edit_bar.edit_pos
-                    edit_bar.set_edit_text(u'{}{}{}'.format(edit_bar.edit_text[:pos], buf_paste, edit_bar.edit_text[pos:]))
-                    edit_bar.edit_pos+=len(buf_paste)
-                else:
-                    # we are not on the edit_bar,
-                    # so we treat pasted text as normal text
-                    if extra is None:
-                        extra = []
-                    extra.extend(input_)
-            if not extra:
-                return
-            input_ = extra
-        ## end of paste detection/handling
-
-        for i in input_:
-            if isinstance(i,tuple):
-                if i[0] == 'mouse press':
-                    if i[1] == 4: #Mouse wheel up
-                        input_[input_.index(i)] = a_key['HISTORY_PREV']
-                    if i[1] == 5: #Mouse wheel down
-                        input_[input_.index(i)] = a_key['HISTORY_NEXT']
-        return input_
-
-    def key_handler(self, input_):
-        if input_ == a_key['MENU_HIDE']:
-            """User want to (un)hide the menu roller"""
-            try:
-                self.main_widget.hide_switch('menu')
-            except AttributeError:
-                pass
-        elif input_ == a_key['NOTIFICATION_NEXT']:
-            """User wants to see next notification"""
-            self.notif_bar.show_next()
-        elif input_ == a_key['OVERLAY_HIDE']:
-            """User wants to (un)hide overlay window"""
-            if isinstance(self.loop.widget,urwid.Overlay):
-                self.__saved_overlay = self.loop.widget
-                self.loop.widget = self.main_widget
-            else:
-                if self.__saved_overlay:
-                    self.loop.widget = self.__saved_overlay
-                    self.__saved_overlay = None
-
-        elif input_ == a_key['DEBUG'] and 'D' in self.bridge.version_get(): #Debug only for dev versions
-            self.debug()
-        elif input_ == a_key['CONTACTS_HIDE']: #user wants to (un)hide the contact lists
-            try:
-                for wid, options in self.center_part.contents:
-                    if self.contact_lists_pile is wid:
-                        self.center_part.contents.remove((wid, options))
-                        break
-                else:
-                    self.center_part.contents.insert(0, (self.contact_lists_pile, ('weight', 2, False)))
-            except AttributeError:
-                #The main widget is not built (probably in Profile Manager)
-                pass
-        elif input_ == 'window resize':
-            width,height = self.loop.screen_size
-            if height<=5 and width<=35:
-                if not 'save_main_widget' in dir(self):
-                    self.save_main_widget = self.loop.widget
-                    self.loop.widget = urwid.Filler(urwid.Text(_("Pleeeeasse, I can't even breathe !")))
-            else:
-                if 'save_main_widget' in dir(self):
-                    self.loop.widget = self.save_main_widget
-                    del self.save_main_widget
-        try:
-            return self.menu_roller.check_shortcuts(input_)
-        except AttributeError:
-            return input_
-
-    def add_menus(self, menu, type_filter, menu_data=None):
-        """Add cached menus to instance
-        @param menu: sat_widgets.Menu instance
-        @param type_filter: menu type like is sat.core.sat_main.import_menu
-        @param menu_data: data to send with these menus
-
-        """
-        def add_menu_cb(callback_id):
-            self.action_launch(callback_id, menu_data, profile=self.current_profile)
-        for id_, type_, path, path_i18n, extra  in self.bridge.menus_get("", C.NO_SECURITY_LIMIT ): # TODO: manage extra
-            if type_ != type_filter:
-                continue
-            if len(path) != 2:
-                raise NotImplementedError("Menu with a path != 2 are not implemented yet")
-            menu.add_menu(path_i18n[0], path_i18n[1], lambda dummy,id_=id_: add_menu_cb(id_))
-
-
-    def _build_menu_roller(self):
-        menu = sat_widgets.Menu(self.loop)
-        general = _("General")
-        menu.add_menu(general, _("Connect"), self.on_connect_request)
-        menu.add_menu(general, _("Disconnect"), self.on_disconnect_request)
-        menu.add_menu(general, _("Parameters"), self.on_param)
-        menu.add_menu(general, _("About"), self.on_about_request)
-        menu.add_menu(general, _("Exit"), self.on_exit_request, a_key['APP_QUIT'])
-        menu.add_menu(_("Contacts"))  # add empty menu to save the place in the menu order
-        groups = _("Groups")
-        menu.add_menu(groups)
-        menu.add_menu(groups, _("Join room"), self.on_join_room_request, a_key['ROOM_JOIN'])
-        #additionals menus
-        #FIXME: do this in a more generic way (in quickapp)
-        self.add_menus(menu, C.MENU_GLOBAL)
-
-        menu_roller = sat_widgets.MenuRoller([(_('Main menu'), menu, C.MENU_ID_MAIN)])
-        return menu_roller
-
-    def _build_main_widget(self):
-        self.contact_lists_pile = urwid.Pile([])
-        #self.center_part = urwid.Columns([('weight',2,self.contact_lists[profile]),('weight',8,Chat('',self))])
-        self.center_part = urwid.Columns([('weight', 2, self.contact_lists_pile), ('weight', 8, urwid.Filler(urwid.Text('')))])
-
-        self.editBar = EditBar(self)
-        self.menu_roller = self._build_menu_roller()
-        self.main_widget = PrimitivusTopWidget(self.center_part, self.menu_roller, self.notif_bar, self.editBar)
-        return self.main_widget
-
-    def plugging_profiles(self):
-        self.loop.widget = self._build_main_widget()
-        self.redraw()
-        try:
-            # if a popup arrived before main widget is build, we need to show it now
-            self.show_pop_up(self._early_popup)
-        except AttributeError:
-            pass
-        else:
-            del self._early_popup
-
-    def profile_plugged(self, profile):
-        QuickApp.profile_plugged(self, profile)
-        contact_list = self.widgets.get_or_create_widget(ContactList, None, on_new_widget=None, on_click=self.contact_selected, on_change=lambda w: self.redraw(), profile=profile)
-        self.contact_lists_pile.contents.append((contact_list, ('weight', 1)))
-        return contact_list
-
-    def is_hidden(self):
-        """Tells if the frontend window is hidden.
-
-        @return bool
-        """
-        return False  # FIXME: implement when necessary
-
-    def alert(self, title, message):
-        """Shortcut method to create an alert message
-
-        Alert will have an "OK" button, which remove it if pressed
-        @param title(unicode): title of the dialog
-        @param message(unicode): body of the dialog
-        @return (urwid_satext.Alert): the created Alert instance
-        """
-        popup = sat_widgets.Alert(title, message)
-        popup.set_callback('ok', lambda dummy: self.remove_pop_up(popup))
-        self.show_pop_up(popup, width=75, height=20)
-        return popup
-
-    def remove_pop_up(self, widget=None):
-        """Remove current pop-up, and if there is other in queue, show it
-
-        @param widget(None, urwid.Widget): if not None remove this popup from front or queue
-        """
-        # TODO: refactor popup management in a cleaner way
-        # buttons' callback use themselve as first argument, and we never use
-        # a Button directly in a popup, so we consider urwid.Button as None
-        if widget is not None and not isinstance(widget, urwid.Button):
-            if isinstance(self.loop.widget, urwid.Overlay):
-                current_popup = self.loop.widget.top_w
-                if not current_popup == widget:
-                    try:
-                        self.notif_bar.remove_pop_up(widget)
-                    except ValueError:
-                        log.warning(u"Trying to remove an unknown widget {}".format(widget))
-                    return
-        self.loop.widget = self.main_widget
-        next_popup = self.notif_bar.get_next_popup()
-        if next_popup:
-            #we still have popup to show, we display it
-            self.show_pop_up(next_popup)
-        else:
-            self.redraw()
-
-    def show_pop_up(self, pop_up_widget, width=None, height=None, align='center',
-                  valign='middle'):
-        """Show a pop-up window if possible, else put it in queue
-
-        @param pop_up_widget: pop up to show
-        @param width(int, None): width of the popup
-            None to use default
-        @param height(int, None): height of the popup
-            None to use default
-        @param align: same as for [urwid.Overlay]
-        """
-        if width == None:
-            width = 75 if isinstance(pop_up_widget, xmlui.PrimitivusNoteDialog) else 135
-        if height == None:
-            height = 20 if isinstance(pop_up_widget, xmlui.PrimitivusNoteDialog) else 40
-        if not isinstance(self.loop.widget, urwid.Overlay):
-            display_widget = urwid.Overlay(
-                pop_up_widget, self.main_widget, align, width, valign, height)
-            self.loop.widget = display_widget
-            self.redraw()
-        else:
-            self.notif_bar.add_pop_up(pop_up_widget)
-
-    def bar_notify(self, message):
-        """"Notify message to user via notification bar"""
-        self.notif_bar.add_message(message)
-        self.redraw()
-
-    def notify(self, type_, entity=None, message=None, subject=None, callback=None, cb_args=None, widget=None, profile=C.PROF_KEY_NONE):
-        if widget is None or widget is not None and widget != self.selected_widget:
-            # we ignore notification if the widget is selected but we can
-            # still do a desktop notification is the X window has not the focus
-            super(PrimitivusApp, self).notify(type_, entity, message, subject, callback, cb_args, widget, profile)
-        # we don't want notifications without message on desktop
-        if message is not None and not self.x_notify.has_focus():
-            if message is None:
-                message = _("{app}: a new event has just happened{entity}").format(
-                    app=C.APP_NAME,
-                    entity=u' ({})'.format(entity) if entity else '')
-            self.x_notify.send_notification(message)
-
-
-    def new_widget(self, widget, user_action=False):
-        """Method called when a new widget is created
-
-        if suitable, the widget will be displayed
-        @param widget(widget.PrimitivusWidget): created widget
-        @param user_action(bool): if True, the widget has been created following an
-            explicit user action. In this case, the widget may get focus immediately
-        """
-        # FIXME: when several widgets are possible (e.g. with :split)
-        #        do not replace current widget when self.selected_widget != None
-        if user_action or self.selected_widget is None:
-            self.select_widget(widget)
-
-    def select_widget(self, widget):
-        """Display a widget if possible,
-
-        else add it in the notification bar queue
-        @param widget: BoxWidget
-        """
-        assert len(self.center_part.widget_list)<=2
-        wid_idx = len(self.center_part.widget_list)-1
-        self.center_part.widget_list[wid_idx] = widget
-        try:
-            self.menu_roller.remove_menu(C.MENU_ID_WIDGET)
-        except KeyError:
-            log.debug("No menu to delete")
-        self.selected_widget = widget
-        try:
-            on_selected = self.selected_widget.on_selected
-        except AttributeError:
-            pass
-        else:
-            on_selected()
-        self._visible_widgets = set([widget]) # XXX: we can only have one widget visible at the time for now
-        self.contact_lists.select(None)
-
-        for wid in self.visible_widgets: # FIXME: check if widgets.get_widgets is not more appropriate
-            if isinstance(wid, Chat):
-                contact_list = self.contact_lists[wid.profile]
-                contact_list.select(wid.target)
-
-        self.redraw()
-
-    def remove_window(self):
-        """Remove window showed on the right column"""
-        #TODO: better Window management than this hack
-        assert len(self.center_part.widget_list) <= 2
-        wid_idx = len(self.center_part.widget_list)-1
-        self.center_part.widget_list[wid_idx] = urwid.Filler(urwid.Text(''))
-        self.center_part.focus_position = 0
-        self.redraw()
-
-    def add_progress(self, pid, message, profile):
-        """Follow a SàT progression
-
-        @param pid: progression id
-        @param message: message to show to identify the progression
-        """
-        self.progress_wid.add(pid, message, profile)
-
-    def set_progress(self, percentage):
-        """Set the progression shown in notification bar"""
-        self.notif_bar.set_progress(percentage)
-
-    def contact_selected(self, contact_list, entity):
-        self.clear_notifs(entity, profile=contact_list.profile)
-        if entity.resource:
-            # we have clicked on a private MUC conversation
-            chat_widget = self.widgets.get_or_create_widget(Chat, entity, on_new_widget=None, force_hash = Chat.get_private_hash(contact_list.profile, entity), profile=contact_list.profile)
-        else:
-            chat_widget = self.widgets.get_or_create_widget(Chat, entity, on_new_widget=None, profile=contact_list.profile)
-        self.select_widget(chat_widget)
-        self.menu_roller.add_menu(_('Chat menu'), chat_widget.get_menu(), C.MENU_ID_WIDGET)
-
-    def _dialog_ok_cb(self, widget, data):
-        popup, answer_cb, answer_data = data
-        self.remove_pop_up(popup)
-        if answer_cb is not None:
-            answer_cb(True, answer_data)
-
-    def _dialog_cancel_cb(self, widget, data):
-        popup, answer_cb, answer_data = data
-        self.remove_pop_up(popup)
-        if answer_cb is not None:
-            answer_cb(False, answer_data)
-
-    def show_dialog(self, message, title="", type="info", answer_cb = None, answer_data = None):
-        if type == 'info':
-            popup = sat_widgets.Alert(title, message, ok_cb=answer_cb)
-            if answer_cb is None:
-                popup.set_callback('ok', lambda dummy: self.remove_pop_up(popup))
-        elif type == 'error':
-            popup = sat_widgets.Alert(title, message, ok_cb=answer_cb)
-            if answer_cb is None:
-                popup.set_callback('ok', lambda dummy: self.remove_pop_up(popup))
-        elif type == 'yes/no':
-            popup = sat_widgets.ConfirmDialog(message)
-            popup.set_callback('yes', self._dialog_ok_cb, (popup, answer_cb, answer_data))
-            popup.set_callback('no', self._dialog_cancel_cb, (popup, answer_cb, answer_data))
-        else:
-            popup = sat_widgets.Alert(title, message, ok_cb=answer_cb)
-            if answer_cb is None:
-                popup.set_callback('ok', lambda dummy: self.remove_pop_up(popup))
-            log.error(u'unmanaged dialog type: {}'.format(type))
-        self.show_pop_up(popup)
-
-    def dialog_failure(self, failure):
-        """Show a failure that has been returned by an asynchronous bridge method.
-
-        @param failure (defer.Failure): Failure instance
-        """
-        self.alert(failure.classname, failure.message)
-
-    def on_notification(self, notif_bar):
-        """Called when a new notification has been received"""
-        if not isinstance(self.main_widget, PrimitivusTopWidget):
-            #if we are not in the main configuration, we ignore the notifications bar
-            return
-        if self.notif_bar.can_hide():
-                #No notification left, we can hide the bar
-                self.main_widget.hide('notif_bar')
-        else:
-            self.main_widget.show('notif_bar')
-            self.redraw() # FIXME: invalidate cache in a more efficient way
-
-    def _action_manager_unknown_error(self):
-        self.alert(_("Error"), _(u"Unmanaged action"))
-
-    def room_joined_handler(self, room_jid_s, room_nicks, user_nick, subject, profile):
-        super(PrimitivusApp, self).room_joined_handler(room_jid_s, room_nicks, user_nick, subject, profile)
-        # if self.selected_widget is None:
-        #     for contact_list in self.widgets.get_widgets(ContactList):
-        #         if profile in contact_list.profiles:
-        #             contact_list.set_focus(jid.JID(room_jid_s), True)
-
-    def progress_started_handler(self, pid, metadata, profile):
-        super(PrimitivusApp, self).progress_started_handler(pid, metadata, profile)
-        self.add_progress(pid, metadata.get('name', _(u'unkown')), profile)
-
-    def progress_finished_handler(self, pid, metadata, profile):
-        log.info(u"Progress {} finished".format(pid))
-        super(PrimitivusApp, self).progress_finished_handler(pid, metadata, profile)
-
-    def progress_error_handler(self, pid, err_msg, profile):
-        log.warning(u"Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg))
-        super(PrimitivusApp, self).progress_error_handler(pid, err_msg, profile)
-
-
-    ##DIALOGS CALLBACKS##
-    def on_join_room(self, button, edit):
-        self.remove_pop_up()
-        room_jid = jid.JID(edit.get_edit_text())
-        self.bridge.muc_join(room_jid, self.profiles[self.current_profile].whoami.node, {}, self.current_profile, callback=lambda dummy: None, errback=self.dialog_failure)
-
-    #MENU EVENTS#
-    def on_connect_request(self, menu):
-        QuickApp.connect(self, self.current_profile)
-
-    def on_disconnect_request(self, menu):
-        self.disconnect(self.current_profile)
-
-    def on_param(self, menu):
-        def success(params):
-            ui = xmlui.create(self, xml_data=params, profile=self.current_profile)
-            ui.show()
-
-        def failure(error):
-            self.alert(_("Error"), _("Can't get parameters (%s)") % error)
-        self.bridge.param_ui_get(app=C.APP_NAME, profile_key=self.current_profile, callback=success, errback=failure)
-
-    def on_exit_request(self, menu):
-        QuickApp.on_exit(self)
-        try:
-            if self._bracketed_mode_set: # we don't unset if bracketed paste mode was detected automatically (i.e. not in conf)
-                log.debug("unsetting bracketed paste mode")
-                sys.stdout.write("\033[?2004l")
-        except AttributeError:
-            pass
-        raise urwid.ExitMainLoop()
-
-    def on_join_room_request(self, menu):
-        """User wants to join a MUC room"""
-        pop_up_widget = sat_widgets.InputDialog(_("Entering a MUC room"), _("Please enter MUC's JID"), default_txt=self.bridge.muc_get_default_service(), ok_cb=self.on_join_room)
-        pop_up_widget.set_callback('cancel', lambda dummy: self.remove_pop_up(pop_up_widget))
-        self.show_pop_up(pop_up_widget)
-
-    def on_about_request(self, menu):
-        self.alert(_("About"), C.APP_NAME + " v" + self.bridge.version_get())
-
-    #MISC CALLBACKS#
-
-    def set_presence_status(self, show='', status=None, profile=C.PROF_KEY_NONE):
-        contact_list_wid = self.widgets.get_widget(ContactList, profiles=profile)
-        if contact_list_wid is not None:
-            contact_list_wid.status_bar.set_presence_status(show, status)
-        else:
-            log.warning(u"No ContactList widget found for profile {}".format(profile))
-
-if __name__ == '__main__':
-    PrimitivusApp().start()
--- a/sat_frontends/primitivus/chat.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,708 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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 functools import total_ordering
-from pathlib import Path
-import bisect
-import urwid
-from urwid_satext import sat_widgets
-from libervia.backend.core.i18n import _
-from libervia.backend.core import log as logging
-from sat_frontends.quick_frontend import quick_widgets
-from sat_frontends.quick_frontend import quick_chat
-from sat_frontends.quick_frontend import quick_games
-from sat_frontends.primitivus import game_tarot
-from sat_frontends.primitivus.constants import Const as C
-from sat_frontends.primitivus.keys import action_key_map as a_key
-from sat_frontends.primitivus.widget import PrimitivusWidget
-from sat_frontends.primitivus.contact_list import ContactList
-
-
-log = logging.getLogger(__name__)
-
-
-OCCUPANTS_FOOTER = _("{} occupants")
-
-
-class MessageWidget(urwid.WidgetWrap, quick_chat.MessageWidget):
-    def __init__(self, mess_data):
-        """
-        @param mess_data(quick_chat.Message, None): message data
-            None: used only for non text widgets (e.g.: focus separator)
-        """
-        self.mess_data = mess_data
-        mess_data.widgets.add(self)
-        super(MessageWidget, self).__init__(urwid.Text(self.markup))
-
-    @property
-    def markup(self):
-        return (
-            self._generate_info_markup()
-            if self.mess_data.type == C.MESS_TYPE_INFO
-            else self._generate_markup()
-        )
-
-    @property
-    def info_type(self):
-        return self.mess_data.info_type
-
-    @property
-    def parent(self):
-        return self.mess_data.parent
-
-    @property
-    def message(self):
-        """Return currently displayed message"""
-        return self.mess_data.main_message
-
-    @message.setter
-    def message(self, value):
-        self.mess_data.message = {"": value}
-        self.redraw()
-
-    @property
-    def type(self):
-        try:
-            return self.mess_data.type
-        except AttributeError:
-            return C.MESS_TYPE_INFO
-
-    def redraw(self):
-        self._w.set_text(self.markup)
-        self.mess_data.parent.host.redraw()  # FIXME: should not be necessary
-
-    def selectable(self):
-        return True
-
-    def keypress(self, size, key):
-        return key
-
-    def get_cursor_coords(self, size):
-        return 0, 0
-
-    def render(self, size, focus=False):
-        # Text widget doesn't render cursor, but we want one
-        # so we add it here
-        canvas = urwid.CompositeCanvas(self._w.render(size, focus))
-        if focus:
-            canvas.set_cursor(self.get_cursor_coords(size))
-        return canvas
-
-    def _generate_info_markup(self):
-        return ("info_msg", self.message)
-
-    def _generate_markup(self):
-        """Generate text markup according to message data and Widget options"""
-        markup = []
-        d = self.mess_data
-        mention = d.mention
-
-        # message status
-        if d.status is None:
-            markup.append(" ")
-        elif d.status == "delivered":
-            markup.append(("msg_status_received", "✔"))
-        else:
-            log.warning("Unknown status: {}".format(d.status))
-
-        # timestamp
-        if self.parent.show_timestamp:
-            attr = "msg_mention" if mention else "date"
-            markup.append((attr, "[{}]".format(d.time_text)))
-        else:
-            if mention:
-                markup.append(("msg_mention", "[*]"))
-
-        # nickname
-        if self.parent.show_short_nick:
-            markup.append(
-                ("my_nick" if d.own_mess else "other_nick", "**" if d.own_mess else "*")
-            )
-        else:
-            markup.append(
-                ("my_nick" if d.own_mess else "other_nick", "[{}] ".format(d.nick or ""))
-            )
-
-        msg = self.message  # needed to generate self.selected_lang
-
-        if d.selected_lang:
-            markup.append(("msg_lang", "[{}] ".format(d.selected_lang)))
-
-        # message body
-        markup.append(msg)
-
-        return markup
-
-    # events
-    def update(self, update_dict=None):
-        """update all the linked message widgets
-
-        @param update_dict(dict, None): key=attribute updated value=new_value
-        """
-        self.redraw()
-
-
-@total_ordering
-class OccupantWidget(urwid.WidgetWrap):
-    def __init__(self, occupant_data):
-        self.occupant_data = occupant_data
-        occupant_data.widgets.add(self)
-        markup = self._generate_markup()
-        text = sat_widgets.ClickableText(markup)
-        urwid.connect_signal(
-            text,
-            "click",
-            self.occupant_data.parent._occupants_clicked,
-            user_args=[self.occupant_data],
-        )
-        super(OccupantWidget, self).__init__(text)
-
-    def __hash__(self):
-        return id(self)
-
-    def __eq__(self, other):
-        if other is None:
-            return False
-        return self.occupant_data.nick == other.occupant_data.nick
-
-    def __lt__(self, other):
-        return self.occupant_data.nick.lower() < other.occupant_data.nick.lower()
-
-    @property
-    def markup(self):
-        return self._generate_markup()
-
-    @property
-    def parent(self):
-        return self.mess_data.parent
-
-    @property
-    def nick(self):
-        return self.occupant_data.nick
-
-    def redraw(self):
-        self._w.set_text(self.markup)
-        self.occupant_data.parent.host.redraw()  # FIXME: should not be necessary
-
-    def selectable(self):
-        return True
-
-    def keypress(self, size, key):
-        return key
-
-    def get_cursor_coords(self, size):
-        return 0, 0
-
-    def render(self, size, focus=False):
-        # Text widget doesn't render cursor, but we want one
-        # so we add it here
-        canvas = urwid.CompositeCanvas(self._w.render(size, focus))
-        if focus:
-            canvas.set_cursor(self.get_cursor_coords(size))
-        return canvas
-
-    def _generate_markup(self):
-        # TODO: role and affiliation are shown in a Q&D way
-        #       should be more intuitive and themable
-        o = self.occupant_data
-        markup = []
-        markup.append(
-            ("info_msg", "{}{} ".format(o.role[0].upper(), o.affiliation[0].upper()))
-        )
-        markup.append(o.nick)
-        if o.state is not None:
-            markup.append(" {}".format(C.CHAT_STATE_ICON[o.state]))
-        return markup
-
-    # events
-    def update(self, update_dict=None):
-        self.redraw()
-
-
-class OccupantsWidget(urwid.WidgetWrap):
-    def __init__(self, parent):
-        self.parent = parent
-        self.occupants_walker = urwid.SimpleListWalker([])
-        self.occupants_footer = urwid.Text("", align="center")
-        self.update_footer()
-        occupants_widget = urwid.Frame(
-            urwid.ListBox(self.occupants_walker), footer=self.occupants_footer
-        )
-        super(OccupantsWidget, self).__init__(occupants_widget)
-        occupants_list = sorted(list(self.parent.occupants.keys()), key=lambda o: o.lower())
-        for occupant in occupants_list:
-            occupant_data = self.parent.occupants[occupant]
-            self.occupants_walker.append(OccupantWidget(occupant_data))
-
-    def clear(self):
-        del self.occupants_walker[:]
-
-    def update_footer(self):
-        """update footer widget"""
-        txt = OCCUPANTS_FOOTER.format(len(self.parent.occupants))
-        self.occupants_footer.set_text(txt)
-
-    def get_nicks(self, start=""):
-        """Return nicks of all occupants
-
-        @param start(unicode): only return nicknames which start with this text
-        """
-        return [
-            w.nick
-            for w in self.occupants_walker
-            if isinstance(w, OccupantWidget) and w.nick.startswith(start)
-        ]
-
-    def addUser(self, occupant_data):
-        """add a user to the list"""
-        bisect.insort(self.occupants_walker, OccupantWidget(occupant_data))
-        self.update_footer()
-        self.parent.host.redraw()  # FIXME: should not be necessary
-
-    def removeUser(self, occupant_data):
-        """remove a user from the list"""
-        for widget in occupant_data.widgets:
-            self.occupants_walker.remove(widget)
-        self.update_footer()
-        self.parent.host.redraw()  # FIXME: should not be necessary
-
-
-class Chat(PrimitivusWidget, quick_chat.QuickChat):
-    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None,
-                 subject=None, statuses=None, profiles=None):
-        self.filters = []  # list of filter callbacks to apply
-        self.mess_walker = urwid.SimpleListWalker([])
-        self.mess_widgets = urwid.ListBox(self.mess_walker)
-        self.chat_widget = urwid.Frame(self.mess_widgets)
-        self.chat_colums = urwid.Columns([("weight", 8, self.chat_widget)])
-        self.pile = urwid.Pile([self.chat_colums])
-        PrimitivusWidget.__init__(self, self.pile, target)
-        quick_chat.QuickChat.__init__(
-            self, host, target, type_, nick, occupants, subject, statuses,
-            profiles=profiles
-        )
-
-        # we must adapt the behaviour with the type
-        if type_ == C.CHAT_GROUP:
-            if len(self.chat_colums.contents) == 1:
-                self.occupants_widget = OccupantsWidget(self)
-                self.occupants_panel = sat_widgets.VerticalSeparator(
-                    self.occupants_widget
-                )
-                self._append_occupants_panel()
-                self.host.addListener("presence", self.presence_listener, [profiles])
-
-        # focus marker is a separator indicated last visible message before focus was lost
-        self.focus_marker = None  # link to current marker
-        self.focus_marker_set = None  # True if a new marker has been inserted
-        self.show_timestamp = True
-        self.show_short_nick = False
-        self.show_title = 1  # 0: clip title; 1: full title; 2: no title
-        self.post_init()
-
-    @property
-    def message_widgets_rev(self):
-        return reversed(self.mess_walker)
-
-    def keypress(self, size, key):
-        if key == a_key["OCCUPANTS_HIDE"]:  # user wants to (un)hide the occupants panel
-            if self.type == C.CHAT_GROUP:
-                widgets = [widget for (widget, options) in self.chat_colums.contents]
-                if self.occupants_panel in widgets:
-                    self._remove_occupants_panel()
-                else:
-                    self._append_occupants_panel()
-        elif key == a_key["TIMESTAMP_HIDE"]:  # user wants to (un)hide timestamp
-            self.show_timestamp = not self.show_timestamp
-            self.redraw()
-        elif key == a_key["SHORT_NICKNAME"]:  # user wants to (not) use short nick
-            self.show_short_nick = not self.show_short_nick
-            self.redraw()
-        elif (key == a_key["SUBJECT_SWITCH"]):
-            # user wants to (un)hide group's subject or change its apperance
-            if self.subject:
-                self.show_title = (self.show_title + 1) % 3
-                if self.show_title == 0:
-                    self.set_subject(self.subject, "clip")
-                elif self.show_title == 1:
-                    self.set_subject(self.subject, "space")
-                elif self.show_title == 2:
-                    self.chat_widget.header = None
-                self._invalidate()
-        elif key == a_key["GOTO_BOTTOM"]:  # user wants to focus last message
-            self.mess_widgets.focus_position = len(self.mess_walker) - 1
-
-        return super(Chat, self).keypress(size, key)
-
-    def completion(self, text, completion_data):
-        """Completion method which complete nicknames in group chat
-
-        for params, see [sat_widgets.AdvancedEdit]
-        """
-        if self.type != C.CHAT_GROUP:
-            return text
-
-        space = text.rfind(" ")
-        start = text[space + 1 :]
-        words = self.occupants_widget.get_nicks(start)
-        if not words:
-            return text
-        try:
-            word_idx = words.index(completion_data["last_word"]) + 1
-        except (KeyError, ValueError):
-            word_idx = 0
-        else:
-            if word_idx == len(words):
-                word_idx = 0
-        word = completion_data["last_word"] = words[word_idx]
-        return "{}{}{}".format(text[: space + 1], word, ": " if space < 0 else "")
-
-    def get_menu(self):
-        """Return Menu bar"""
-        menu = sat_widgets.Menu(self.host.loop)
-        if self.type == C.CHAT_GROUP:
-            self.host.add_menus(menu, C.MENU_ROOM, {"room_jid": self.target.bare})
-            game = _("Game")
-            menu.add_menu(game, "Tarot", self.on_tarot_request)
-        elif self.type == C.CHAT_ONE2ONE:
-            # FIXME: self.target is a bare jid, we need to check that
-            contact_list = self.host.contact_lists[self.profile]
-            if not self.target.resource:
-                full_jid = contact_list.get_full_jid(self.target)
-            else:
-                full_jid = self.target
-            self.host.add_menus(menu, C.MENU_SINGLE, {"jid": full_jid})
-        return menu
-
-    def set_filter(self, args):
-        """set filtering of messages
-
-        @param args(list[unicode]): filters following syntax "[filter]=[value]"
-            empty list to clear all filters
-            only lang=XX is handled for now
-        """
-        del self.filters[:]
-        if args:
-            if args[0].startswith("lang="):
-                lang = args[0][5:].strip()
-                self.filters.append(lambda mess_data: lang in mess_data.message)
-
-        self.print_messages()
-
-    def presence_listener(self, entity, show, priority, statuses, profile):
-        """Update entity's presence status
-
-        @param entity (jid.JID): entity updated
-        @param show: availability
-        @param priority: resource's priority
-        @param statuses: dict of statuses
-        @param profile: %(doc_profile)s
-        """
-        # FIXME: disable for refactoring, need to be checked and re-enabled
-        return
-        # assert self.type == C.CHAT_GROUP
-        # if entity.bare != self.target:
-        #     return
-        # self.update(entity)
-
-    def create_message(self, message):
-        self.appendMessage(message)
-
-    def _scrollDown(self):
-        """scroll down message only if we are already at the bottom (minus 1)"""
-        current_focus = self.mess_widgets.focus_position
-        bottom = len(self.mess_walker) - 1
-        if current_focus == bottom - 1:
-            self.mess_widgets.focus_position = bottom  # scroll down
-        self.host.redraw()  # FIXME: should not be necessary
-
-    def appendMessage(self, message, minor_notifs=True):
-        """Create a MessageWidget and append it
-
-        Can merge info messages together if desirable (e.g.: multiple joined/leave)
-        @param message(quick_chat.Message): message to add
-        @param minor_notifs(boolean): if True, basic notifications are allowed
-            If False, notification are not shown except if we have an important one
-            (like a mention).
-            False is generally used when printing history, when we don't want every
-            message to be notified.
-        """
-        if message.attachments:
-            # FIXME: Q&D way to see attachments in Primitivus
-            #   it should be done in a more user friendly way
-            for lang, body in message.message.items():
-                for attachment in message.attachments:
-                    if 'url' in attachment:
-                        body+=f"\n{attachment['url']}"
-                    elif 'path' in attachment:
-                        path = Path(attachment['path'])
-                        body+=f"\n{path.as_uri()}"
-                    else:
-                        log.warning(f'No "url" nor "path" in attachment: {attachment}')
-                    message.message[lang] = body
-
-        if self.filters:
-            if not all([f(message) for f in self.filters]):
-                return
-
-        if self.handle_user_moved(message):
-            return
-
-        if ((self.host.selected_widget != self or not self.host.x_notify.has_focus())
-            and self.focus_marker_set is not None):
-            if not self.focus_marker_set and not self._locked and self.mess_walker:
-                if self.focus_marker is not None:
-                    try:
-                        self.mess_walker.remove(self.focus_marker)
-                    except ValueError:
-                        # self.focus_marker may not be in mess_walker anymore if
-                        # mess_walker has been cleared, e.g. when showing search
-                        # result or using :history command
-                        pass
-                self.focus_marker = urwid.Divider("—")
-                self.mess_walker.append(self.focus_marker)
-                self.focus_marker_set = True
-                self._scrollDown()
-        else:
-            if self.focus_marker_set:
-                self.focus_marker_set = False
-
-        wid = MessageWidget(message)
-        self.mess_walker.append(wid)
-        self._scrollDown()
-        if self.is_user_moved(message):
-            return  # no notification for moved messages
-
-        # notifications
-
-        if self._locked:
-            # we don't want notifications when locked
-            # because that's history messages
-            return
-
-        if wid.mess_data.mention:
-            from_jid = wid.mess_data.from_jid
-            msg = _(
-                "You have been mentioned by {nick} in {room}".format(
-                    nick=wid.mess_data.nick, room=self.target
-                )
-            )
-            self.host.notify(
-                C.NOTIFY_MENTION, from_jid, msg, widget=self, profile=self.profile
-            )
-        elif not minor_notifs:
-            return
-        elif self.type == C.CHAT_ONE2ONE:
-            from_jid = wid.mess_data.from_jid
-            msg = _("{entity} is talking to you".format(entity=from_jid))
-            self.host.notify(
-                C.NOTIFY_MESSAGE, from_jid, msg, widget=self, profile=self.profile
-            )
-        else:
-            self.host.notify(
-                C.NOTIFY_MESSAGE, self.target, widget=self, profile=self.profile
-            )
-
-    def addUser(self, nick):
-        occupant = super(Chat, self).addUser(nick)
-        self.occupants_widget.addUser(occupant)
-
-    def removeUser(self, occupant_data):
-        occupant = super(Chat, self).removeUser(occupant_data)
-        if occupant is not None:
-            self.occupants_widget.removeUser(occupant)
-
-    def occupants_clear(self):
-        super(Chat, self).occupants_clear()
-        self.occupants_widget.clear()
-
-    def _occupants_clicked(self, occupant, clicked_wid):
-        assert self.type == C.CHAT_GROUP
-        contact_list = self.host.contact_lists[self.profile]
-
-        # we have a click on a nick, we need to create the widget if it doesn't exists
-        self.get_or_create_private_widget(occupant.jid)
-
-        # now we select the new window
-        for contact_list in self.host.widgets.get_widgets(
-            ContactList, profiles=(self.profile,)
-        ):
-            contact_list.set_focus(occupant.jid, True)
-
-    def _append_occupants_panel(self):
-        self.chat_colums.contents.append((self.occupants_panel, ("weight", 2, False)))
-
-    def _remove_occupants_panel(self):
-        for widget, options in self.chat_colums.contents:
-            if widget is self.occupants_panel:
-                self.chat_colums.contents.remove((widget, options))
-                break
-
-    def add_game_panel(self, widget):
-        """Insert a game panel to this Chat dialog.
-
-        @param widget (Widget): the game panel
-        """
-        assert len(self.pile.contents) == 1
-        self.pile.contents.insert(0, (widget, ("weight", 1)))
-        self.pile.contents.insert(1, (urwid.Filler(urwid.Divider("-"), ("fixed", 1))))
-        self.host.redraw()
-
-    def remove_game_panel(self, widget):
-        """Remove the game panel from this Chat dialog.
-
-        @param widget (Widget): the game panel
-        """
-        assert len(self.pile.contents) == 3
-        del self.pile.contents[0]
-        self.host.redraw()
-
-    def set_subject(self, subject, wrap="space"):
-        """Set title for a group chat"""
-        quick_chat.QuickChat.set_subject(self, subject)
-        self.subj_wid = urwid.Text(
-            str(subject.replace("\n", "|") if wrap == "clip" else subject),
-            align="left" if wrap == "clip" else "center",
-            wrap=wrap,
-        )
-        self.chat_widget.header = urwid.AttrMap(self.subj_wid, "title")
-        self.host.redraw()
-
-    ## Messages
-
-    def print_messages(self, clear=True):
-        """generate message widgets
-
-        @param clear(bool): clear message before printing if true
-        """
-        if clear:
-            del self.mess_walker[:]
-        for message in self.messages.values():
-            self.appendMessage(message, minor_notifs=False)
-
-    def redraw(self):
-        """redraw all messages"""
-        for w in self.mess_walker:
-            try:
-                w.redraw()
-            except AttributeError:
-                pass
-
-    def update_history(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile="@NONE@"):
-        del self.mess_walker[:]
-        if filters and "search" in filters:
-            self.mess_walker.append(
-                urwid.Text(
-                    _("Results for searching the globbing pattern: {}").format(
-                        filters["search"]
-                    )
-                )
-            )
-            self.mess_walker.append(
-                urwid.Text(_("Type ':history <lines>' to reset the chat history"))
-            )
-        super(Chat, self).update_history(size, filters, profile)
-
-    def _on_history_printed(self):
-        """Refresh or scroll down the focus after the history is printed"""
-        self.print_messages(clear=False)
-        super(Chat, self)._on_history_printed()
-
-    def on_private_created(self, widget):
-        self.host.contact_lists[widget.profile].set_special(
-            widget.target, C.CONTACT_SPECIAL_GROUP
-        )
-
-    def on_selected(self):
-        self.focus_marker_set = False
-
-    def notify(self, contact="somebody", msg=""):
-        """Notify the user of a new message if primitivus doesn't have the focus.
-
-        @param contact (unicode): contact who wrote to the users
-        @param msg (unicode): the message that has been received
-        """
-        # FIXME: not called anymore after refactoring
-        if msg == "":
-            return
-        if self.mess_widgets.get_focus()[1] == len(self.mess_walker) - 2:
-            # we don't change focus if user is not at the bottom
-            # as that mean that he is probably watching discussion history
-            self.mess_widgets.focus_position = len(self.mess_walker) - 1
-        self.host.redraw()
-        if not self.host.x_notify.has_focus():
-            if self.type == C.CHAT_ONE2ONE:
-                self.host.x_notify.send_notification(
-                    _("Primitivus: %s is talking to you") % contact
-                )
-            elif self.nick is not None and self.nick.lower() in msg.lower():
-                self.host.x_notify.send_notification(
-                    _("Primitivus: %(user)s mentioned you in room '%(room)s'")
-                    % {"user": contact, "room": self.target}
-                )
-
-    # MENU EVENTS #
-    def on_tarot_request(self, menu):
-        # TODO: move this to plugin_misc_tarot with dynamic menu
-        if len(self.occupants) != 4:
-            self.host.show_pop_up(
-                sat_widgets.Alert(
-                    _("Can't start game"),
-                    _(
-                        "You need to be exactly 4 peoples in the room to start a Tarot game"
-                    ),
-                    ok_cb=self.host.remove_pop_up,
-                )
-            )
-        else:
-            self.host.bridge.tarot_game_create(
-                self.target, list(self.occupants), self.profile
-            )
-
-    # MISC EVENTS #
-
-    def on_delete(self):
-        # FIXME: to be checked after refactoring
-        super(Chat, self).on_delete()
-        if self.type == C.CHAT_GROUP:
-            self.host.removeListener("presence", self.presence_listener)
-
-    def on_chat_state(self, from_jid, state, profile):
-        super(Chat, self).on_chat_state(from_jid, state, profile)
-        if self.type == C.CHAT_ONE2ONE:
-            self.title_dynamic = C.CHAT_STATE_ICON[state]
-            self.host.redraw()  # FIXME: should not be necessary
-
-    def _on_subject_dialog_cb(self, button, dialog):
-        self.change_subject(dialog.text)
-        self.host.remove_pop_up(dialog)
-
-    def on_subject_dialog(self, new_subject=None):
-        dialog = sat_widgets.InputDialog(
-            _("Change title"),
-            _("Enter the new title"),
-            default_txt=new_subject if new_subject is not None else self.subject,
-        )
-        dialog.set_callback("ok", self._on_subject_dialog_cb, dialog)
-        dialog.set_callback("cancel", lambda __: self.host.remove_pop_up(dialog))
-        self.host.show_pop_up(dialog)
-
-
-quick_widgets.register(quick_chat.QuickChat, Chat)
-quick_widgets.register(quick_games.Tarot, game_tarot.TarotGame)
--- a/sat_frontends/primitivus/config.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,50 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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/>.
-
-"""This module manage configuration specific to Primitivus"""
-
-from sat_frontends.primitivus.constants import Const as C
-from sat_frontends.primitivus.keys import action_key_map
-import configparser
-
-
-def apply_config(host):
-    """Parse configuration and apply found change
-
-    raise: can raise various Exceptions if configuration is not good
-    """
-    config = configparser.SafeConfigParser()
-    config.read(C.CONFIG_FILES)
-    try:
-        options = config.items(C.CONFIG_SECTION)
-    except configparser.NoSectionError:
-        options = []
-    shortcuts = {}
-    for name, value in options:
-        if name.startswith(C.CONFIG_OPT_KEY_PREFIX.lower()):
-            action = name[len(C.CONFIG_OPT_KEY_PREFIX) :].upper()
-            shortcut = value
-            if not action or not shortcut:
-                raise ValueError("Bad option: {} = {}".format(name, value))
-            shortcuts[action] = shortcut
-        if name == "disable_mouse":
-            host.loop.screen.set_mouse_tracking(False)
-
-    action_key_map.replace(shortcuts)
-    action_key_map.check_namespaces()
--- a/sat_frontends/primitivus/constants.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,109 +0,0 @@
-#!/usr/bin/env python3
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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_frontends.quick_frontend import constants
-
-
-class Const(constants.Const):
-
-    APP_NAME = "Libervia TUI"
-    APP_COMPONENT = "TUI"
-    APP_NAME_ALT = "Primitivus"
-    APP_NAME_FILE = "libervia_tui"
-    CONFIG_SECTION = APP_COMPONENT.lower()
-    PALETTE = [
-        ("title", "black", "light gray", "standout,underline"),
-        ("title_focus", "white,bold", "light gray", "standout,underline"),
-        ("selected", "default", "dark red"),
-        ("selected_focus", "default,bold", "dark red"),
-        ("default", "default", "default"),
-        ("default_focus", "default,bold", "default"),
-        ("cl_notifs", "yellow", "default"),
-        ("cl_notifs_focus", "yellow,bold", "default"),
-        ("cl_mention", "light red", "default"),
-        ("cl_mention_focus", "dark red,bold", "default"),
-        # Messages
-        ("date", "light gray", "default"),
-        ("my_nick", "dark red,bold", "default"),
-        ("other_nick", "dark cyan,bold", "default"),
-        ("info_msg", "yellow", "default", "bold"),
-        ("msg_lang", "dark cyan", "default"),
-        ("msg_mention", "dark red, bold", "default"),
-        ("msg_status_received", "light green, bold", "default"),
-        ("menubar", "light gray,bold", "dark red"),
-        ("menubar_focus", "light gray,bold", "dark green"),
-        ("selected_menu", "light gray,bold", "dark green"),
-        ("menuitem", "light gray,bold", "dark red"),
-        ("menuitem_focus", "light gray,bold", "dark green"),
-        ("notifs", "black,bold", "yellow"),
-        ("notifs_focus", "dark red", "yellow"),
-        ("card_neutral", "dark gray", "white", "standout,underline"),
-        ("card_neutral_selected", "dark gray", "dark green", "standout,underline"),
-        ("card_special", "brown", "white", "standout,underline"),
-        ("card_special_selected", "brown", "dark green", "standout,underline"),
-        ("card_red", "dark red", "white", "standout,underline"),
-        ("card_red_selected", "dark red", "dark green", "standout,underline"),
-        ("card_black", "black", "white", "standout,underline"),
-        ("card_black_selected", "black", "dark green", "standout,underline"),
-        ("directory", "dark cyan, bold", "default"),
-        ("directory_focus", "dark cyan, bold", "dark green"),
-        ("separator", "brown", "default"),
-        ("warning", "light red", "default"),
-        ("progress_normal", "default", "brown"),
-        ("progress_complete", "default", "dark green"),
-        ("show_disconnected", "dark gray", "default"),
-        ("show_normal", "default", "default"),
-        ("show_normal_focus", "default, bold", "default"),
-        ("show_chat", "dark green", "default"),
-        ("show_chat_focus", "dark green, bold", "default"),
-        ("show_away", "brown", "default"),
-        ("show_away_focus", "brown, bold", "default"),
-        ("show_dnd", "dark red", "default"),
-        ("show_dnd_focus", "dark red, bold", "default"),
-        ("show_xa", "dark red", "default"),
-        ("show_xa_focus", "dark red, bold", "default"),
-        ("resource", "light blue", "default"),
-        ("resource_main", "dark blue", "default"),
-        ("status", "yellow", "default"),
-        ("status_focus", "yellow, bold", "default"),
-        ("param_selected", "default, bold", "dark red"),
-        ("table_selected", "default, bold", "default"),
-    ]
-    PRESENCE = {
-        "unavailable": ("⨯", "show_disconnected"),
-        "": ("✔", "show_normal"),
-        "chat": ("✆", "show_chat"),
-        "away": ("✈", "show_away"),
-        "dnd": ("✖", "show_dnd"),
-        "xa": ("☄", "show_xa"),
-    }
-    LOG_OPT_SECTION = APP_NAME.lower()
-    LOG_OPT_OUTPUT = (
-        "output",
-        constants.Const.LOG_OPT_OUTPUT_SEP + constants.Const.LOG_OPT_OUTPUT_MEMORY,
-    )
-    CONFIG_OPT_KEY_PREFIX = "KEY_"
-
-    MENU_ID_MAIN = "MAIN_MENU"
-    MENU_ID_WIDGET = "WIDGET_MENU"
-
-    MODE_NORMAL = "NORMAL"
-    MODE_INSERTION = "INSERTION"
-    MODE_COMMAND = "COMMAND"
-
-    GROUP_DATA_FOLDED = "folded"
--- a/sat_frontends/primitivus/contact_list.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,364 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-import urwid
-from urwid_satext import sat_widgets
-from sat_frontends.quick_frontend.quick_contact_list import QuickContactList
-from sat_frontends.primitivus.status import StatusBar
-from sat_frontends.primitivus.constants import Const as C
-from sat_frontends.primitivus.keys import action_key_map as a_key
-from sat_frontends.primitivus.widget import PrimitivusWidget
-from sat_frontends.tools import jid
-from libervia.backend.core import log as logging
-
-log = logging.getLogger(__name__)
-from sat_frontends.quick_frontend import quick_widgets
-
-
-class ContactList(PrimitivusWidget, QuickContactList):
-    PROFILES_MULTIPLE = False
-    PROFILES_ALLOW_NONE = False
-    signals = ["click", "change"]
-    # FIXME: Only single profile is managed so far
-
-    def __init__(
-        self, host, target, on_click=None, on_change=None, user_data=None, profiles=None
-    ):
-        QuickContactList.__init__(self, host, profiles)
-        self.contact_list = self.host.contact_lists[self.profile]
-
-        # we now build the widget
-        self.status_bar = StatusBar(host)
-        self.frame = sat_widgets.FocusFrame(self._build_list(), None, self.status_bar)
-        PrimitivusWidget.__init__(self, self.frame, _("Contacts"))
-        if on_click:
-            urwid.connect_signal(self, "click", on_click, user_data)
-        if on_change:
-            urwid.connect_signal(self, "change", on_change, user_data)
-        self.host.addListener("notification", self.on_notification, [self.profile])
-        self.host.addListener("notificationsClear", self.on_notification, [self.profile])
-        self.post_init()
-
-    def update(self, entities=None, type_=None, profile=None):
-        """Update display, keep focus"""
-        # FIXME: full update is done each time, must handle entities, type_ and profile
-        widget, position = self.frame.body.get_focus()
-        self.frame.body = self._build_list()
-        if position:
-            try:
-                self.frame.body.focus_position = position
-            except IndexError:
-                pass
-        self._invalidate()
-        self.host.redraw()  # FIXME: check if can be avoided
-
-    def keypress(self, size, key):
-        # FIXME: we have a temporary behaviour here: FOCUS_SWITCH change focus globally in the parent,
-        #        and FOCUS_UP/DOWN is transwmitter to parent if we are respectively on the first or last element
-        if key in sat_widgets.FOCUS_KEYS:
-            if (
-                key == a_key["FOCUS_SWITCH"]
-                or (key == a_key["FOCUS_UP"] and self.frame.focus_position == "body")
-                or (key == a_key["FOCUS_DOWN"] and self.frame.focus_position == "footer")
-            ):
-                return key
-        if key == a_key["STATUS_HIDE"]:  # user wants to (un)hide contacts' statuses
-            self.contact_list.show_status = not self.contact_list.show_status
-            self.update()
-        elif (
-            key == a_key["DISCONNECTED_HIDE"]
-        ):  # user wants to (un)hide disconnected contacts
-            self.host.bridge.param_set(
-                C.SHOW_OFFLINE_CONTACTS,
-                C.bool_const(not self.contact_list.show_disconnected),
-                "General",
-                profile_key=self.profile,
-            )
-        elif key == a_key["RESOURCES_HIDE"]:  # user wants to (un)hide contacts resources
-            self.contact_list.show_resources(not self.contact_list.show_resources)
-            self.update()
-        return super(ContactList, self).keypress(size, key)
-
-    # QuickWidget methods
-
-    @staticmethod
-    def get_widget_hash(target, profiles):
-        profiles = sorted(profiles)
-        return tuple(profiles)
-
-    # modify the contact list
-
-    def set_focus(self, text, select=False):
-        """give focus to the first element that matches the given text. You can also
-        pass in text a sat_frontends.tools.jid.JID (it's a subclass of unicode).
-
-        @param text: contact group name, contact or muc userhost, muc private dialog jid
-        @param select: if True, the element is also clicked
-        """
-        idx = 0
-        for widget in self.frame.body.body:
-            try:
-                if isinstance(widget, sat_widgets.ClickableText):
-                    # contact group
-                    value = widget.get_value()
-                elif isinstance(widget, sat_widgets.SelectableText):
-                    # contact or muc
-                    value = widget.data
-                else:
-                    # Divider instance
-                    continue
-                # there's sometimes a leading space
-                if text.strip() == value.strip():
-                    self.frame.body.focus_position = idx
-                    if select:
-                        self._contact_clicked(False, widget, True)
-                    return
-            except AttributeError:
-                pass
-            idx += 1
-
-        log.debug("Not element found for {} in set_focus".format(text))
-
-    # events
-
-    def _group_clicked(self, group_wid):
-        group = group_wid.get_value()
-        data = self.contact_list.get_group_data(group)
-        data[C.GROUP_DATA_FOLDED] = not data.setdefault(C.GROUP_DATA_FOLDED, False)
-        self.set_focus(group)
-        self.update()
-
-    def _contact_clicked(self, use_bare_jid, contact_wid, selected):
-        """Method called when a contact is clicked
-
-        @param use_bare_jid: True if use_bare_jid is set in self._build_entity_widget.
-        @param contact_wid: widget of the contact, must have the entity set in data attribute
-        @param selected: boolean returned by the widget, telling if it is selected
-        """
-        entity = contact_wid.data
-        self.host.mode_hint(C.MODE_INSERTION)
-        self._emit("click", entity)
-
-    def on_notification(self, entity, notif, profile):
-        notifs = list(self.host.get_notifs(C.ENTITY_ALL, profile=self.profile))
-        if notifs:
-            self.title_dynamic = "({})".format(len(notifs))
-        else:
-            self.title_dynamic = None
-        self.host.redraw()  # FIXME: should not be necessary
-
-    # Methods to build the widget
-
-    def _build_entity_widget(
-        self,
-        entity,
-        keys=None,
-        use_bare_jid=False,
-        with_notifs=True,
-        with_show_attr=True,
-        markup_prepend=None,
-        markup_append=None,
-        special=False,
-    ):
-        """Build one contact markup data
-
-        @param entity (jid.JID): entity to build
-        @param keys (iterable): value to markup, in preferred order.
-            The first available key will be used.
-            If key starts with "cache_", it will be checked in cache,
-            else, getattr will be done on entity with the key (e.g. getattr(entity, 'node')).
-            If nothing full or keys is None, full entity is used.
-        @param use_bare_jid (bool): if True, use bare jid for selected comparisons
-        @param with_notifs (bool): if True, show notification count
-        @param with_show_attr (bool): if True, show color corresponding to presence status
-        @param markup_prepend (list): markup to prepend to the generated one before building the widget
-        @param markup_append (list): markup to append to the generated one before building the widget
-        @param special (bool): True if entity is a special one
-        @return (list): markup data are expected by Urwid text widgets
-        """
-        markup = []
-        if use_bare_jid:
-            selected = {entity.bare for entity in self.contact_list._selected}
-        else:
-            selected = self.contact_list._selected
-        if keys is None:
-            entity_txt = entity
-        else:
-            cache = self.contact_list.getCache(entity)
-            for key in keys:
-                if key.startswith("cache_"):
-                    entity_txt = cache.get(key[6:])
-                else:
-                    entity_txt = getattr(entity, key)
-                if entity_txt:
-                    break
-            if not entity_txt:
-                entity_txt = entity
-
-        if with_show_attr:
-            show = self.contact_list.getCache(entity, C.PRESENCE_SHOW, default=None)
-            if show is None:
-                show = C.PRESENCE_UNAVAILABLE
-            show_icon, entity_attr = C.PRESENCE.get(show, ("", "default"))
-            markup.insert(0, "{} ".format(show_icon))
-        else:
-            entity_attr = "default"
-
-        notifs = list(
-            self.host.get_notifs(entity, exact_jid=special, profile=self.profile)
-        )
-        mentions = list(
-                self.host.get_notifs(entity.bare, C.NOTIFY_MENTION, profile=self.profile)
-            )
-        if notifs or mentions:
-            attr = 'cl_mention' if mentions else 'cl_notifs'
-            header = [(attr, "({})".format(len(notifs) + len(mentions))), " "]
-        else:
-            header = ""
-
-        markup.append((entity_attr, entity_txt))
-        if markup_prepend:
-            markup.insert(0, markup_prepend)
-        if markup_append:
-            markup.extend(markup_append)
-
-        widget = sat_widgets.SelectableText(
-            markup, selected=entity in selected, header=header
-        )
-        widget.data = entity
-        widget.comp = entity_txt.lower()  # value to use for sorting
-        urwid.connect_signal(
-            widget, "change", self._contact_clicked, user_args=[use_bare_jid]
-        )
-        return widget
-
-    def _build_entities(self, content, entities):
-        """Add entity representation in widget list
-
-        @param content: widget list, e.g. SimpleListWalker
-        @param entities (iterable): iterable of JID to display
-        """
-        if not entities:
-            return
-        widgets = []  # list of built widgets
-
-        for entity in entities:
-            if (
-                entity in self.contact_list._specials
-                or not self.contact_list.entity_visible(entity)
-            ):
-                continue
-            markup_extra = []
-            if self.contact_list.show_resources:
-                for resource in self.contact_list.getCache(entity, C.CONTACT_RESOURCES):
-                    resource_disp = (
-                        "resource_main"
-                        if resource
-                        == self.contact_list.getCache(entity, C.CONTACT_MAIN_RESOURCE)
-                        else "resource",
-                        "\n  " + resource,
-                    )
-                    markup_extra.append(resource_disp)
-                    if self.contact_list.show_status:
-                        status = self.contact_list.getCache(
-                            jid.JID("%s/%s" % (entity, resource)), "status", default=None
-                        )
-                        status_disp = ("status", "\n    " + status) if status else ""
-                        markup_extra.append(status_disp)
-
-            else:
-                if self.contact_list.show_status:
-                    status = self.contact_list.getCache(entity, "status", default=None)
-                    status_disp = ("status", "\n  " + status) if status else ""
-                    markup_extra.append(status_disp)
-            widget = self._build_entity_widget(
-                entity,
-                ("cache_nick", "cache_name", "node"),
-                use_bare_jid=True,
-                markup_append=markup_extra,
-            )
-            widgets.append(widget)
-
-        widgets.sort(key=lambda widget: widget.comp)
-
-        for widget in widgets:
-            content.append(widget)
-
-    def _build_specials(self, content):
-        """Build the special entities"""
-        specials = sorted(self.contact_list.get_specials())
-        current = None
-        for entity in specials:
-            if current is not None and current.bare == entity.bare:
-                # nested entity (e.g. MUC private conversations)
-                widget = self._build_entity_widget(
-                    entity, ("resource",), markup_prepend="  ", special=True
-                )
-            else:
-                # the special widgets
-                if entity.resource:
-                    widget = self._build_entity_widget(entity, ("resource",), special=True)
-                else:
-                    widget = self._build_entity_widget(
-                        entity,
-                        ("cache_nick", "cache_name", "node"),
-                        with_show_attr=False,
-                        special=True,
-                    )
-            content.append(widget)
-
-    def _build_list(self):
-        """Build the main contact list widget"""
-        content = urwid.SimpleListWalker([])
-
-        self._build_specials(content)
-        if self.contact_list._specials:
-            content.append(urwid.Divider("="))
-
-        groups = list(self.contact_list._groups)
-        groups.sort(key=lambda x: x.lower() if x else '')
-        for group in groups:
-            data = self.contact_list.get_group_data(group)
-            folded = data.get(C.GROUP_DATA_FOLDED, False)
-            jids = list(data["jids"])
-            if group is not None and (
-                self.contact_list.any_entity_visible(jids)
-                or self.contact_list.show_empty_groups
-            ):
-                header = "[-]" if not folded else "[+]"
-                widget = sat_widgets.ClickableText(group, header=header + " ")
-                content.append(widget)
-                urwid.connect_signal(widget, "click", self._group_clicked)
-            if not folded:
-                self._build_entities(content, jids)
-        not_in_roster = (
-            set(self.contact_list._cache)
-            .difference(self.contact_list._roster)
-            .difference(self.contact_list._specials)
-            .difference((self.contact_list.whoami.bare,))
-        )
-        if not_in_roster:
-            content.append(urwid.Divider("-"))
-            self._build_entities(content, not_in_roster)
-
-        return urwid.ListBox(content)
-
-
-quick_widgets.register(QuickContactList, ContactList)
--- a/sat_frontends/primitivus/game_tarot.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,397 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-import urwid
-from urwid_satext import sat_widgets
-from sat_frontends.tools.games import TarotCard
-from sat_frontends.quick_frontend.quick_game_tarot import QuickTarotGame
-from sat_frontends.primitivus import xmlui
-from sat_frontends.primitivus.keys import action_key_map as a_key
-
-
-class CardDisplayer(urwid.Text):
-    """Show a card"""
-
-    signals = ["click"]
-
-    def __init__(self, card):
-        self.__selected = False
-        self.card = card
-        urwid.Text.__init__(self, card.get_attr_text())
-
-    def selectable(self):
-        return True
-
-    def keypress(self, size, key):
-        if key == a_key["CARD_SELECT"]:
-            self.select(not self.__selected)
-            self._emit("click")
-        return key
-
-    def mouse_event(self, size, event, button, x, y, focus):
-        if urwid.is_mouse_event(event) and button == 1:
-            self.select(not self.__selected)
-            self._emit("click")
-            return True
-
-        return False
-
-    def select(self, state=True):
-        self.__selected = state
-        attr, txt = self.card.get_attr_text()
-        if self.__selected:
-            attr += "_selected"
-        self.set_text((attr, txt))
-        self._invalidate()
-
-    def is_selected(self):
-        return self.__selected
-
-    def get_card(self):
-        return self.card
-
-    def render(self, size, focus=False):
-        canvas = urwid.CompositeCanvas(urwid.Text.render(self, size, focus))
-        if focus:
-            canvas.set_cursor((0, 0))
-        return canvas
-
-
-class Hand(urwid.WidgetWrap):
-    """Used to display several cards, and manage a hand"""
-
-    signals = ["click"]
-
-    def __init__(self, hand=[], selectable=False, on_click=None, user_data=None):
-        """@param hand: list of Card"""
-        self.__selectable = selectable
-        self.columns = urwid.Columns([], dividechars=1)
-        if on_click:
-            urwid.connect_signal(self, "click", on_click, user_data)
-        if hand:
-            self.update(hand)
-        urwid.WidgetWrap.__init__(self, self.columns)
-
-    def selectable(self):
-        return self.__selectable
-
-    def keypress(self, size, key):
-
-        if CardDisplayer in [wid.__class__ for wid in self.columns.widget_list]:
-            return self.columns.keypress(size, key)
-        else:
-            # No card displayed, we still have to manage the clicks
-            if key == a_key["CARD_SELECT"]:
-                self._emit("click", None)
-            return key
-
-    def get_selected(self):
-        """Return a list of selected cards"""
-        _selected = []
-        for wid in self.columns.widget_list:
-            if isinstance(wid, CardDisplayer) and wid.is_selected():
-                _selected.append(wid.get_card())
-        return _selected
-
-    def update(self, hand):
-        """Update the hand displayed in this widget
-        @param hand: list of Card"""
-        try:
-            del self.columns.widget_list[:]
-            del self.columns.column_types[:]
-        except IndexError:
-            pass
-        self.columns.contents.append((urwid.Text(""), ("weight", 1, False)))
-        for card in hand:
-            widget = CardDisplayer(card)
-            self.columns.widget_list.append(widget)
-            self.columns.column_types.append(("fixed", 3))
-            urwid.connect_signal(widget, "click", self.__on_click)
-        self.columns.contents.append((urwid.Text(""), ("weight", 1, False)))
-        self.columns.focus_position = 1
-
-    def __on_click(self, card_wid):
-        self._emit("click", card_wid)
-
-
-class Card(TarotCard):
-    """This class is used to represent a card, logically
-    and give a text representation with attributes"""
-
-    SIZE = 3  # size of a displayed card
-
-    def __init__(self, suit, value):
-        """@param file: path of the PNG file"""
-        TarotCard.__init__(self, (suit, value))
-
-    def get_attr_text(self):
-        """return text representation of the card with attributes"""
-        try:
-            value = "%02i" % int(self.value)
-        except ValueError:
-            value = self.value[0].upper() + self.value[1]
-        if self.suit == "atout":
-            if self.value == "excuse":
-                suit = "c"
-            else:
-                suit = "A"
-            color = "neutral"
-        elif self.suit == "pique":
-            suit = "♠"
-            color = "black"
-        elif self.suit == "trefle":
-            suit = "♣"
-            color = "black"
-        elif self.suit == "coeur":
-            suit = "♥"
-            color = "red"
-        elif self.suit == "carreau":
-            suit = "♦"
-            color = "red"
-        if self.bout:
-            color = "special"
-        return ("card_%s" % color, "%s%s" % (value, suit))
-
-    def get_widget(self):
-        """Return a widget representing the card"""
-        return CardDisplayer(self)
-
-
-class Table(urwid.FlowWidget):
-    """Represent the cards currently on the table"""
-
-    def __init__(self):
-        self.top = self.left = self.bottom = self.right = None
-
-    def put_card(self, location, card):
-        """Put a card on the table
-        @param location: where to put the card (top, left, bottom or right)
-        @param card: Card to play or None"""
-        assert location in ["top", "left", "bottom", "right"]
-        assert isinstance(card, Card) or card == None
-        if [getattr(self, place) for place in ["top", "left", "bottom", "right"]].count(
-            None
-        ) == 0:
-            # If the table is full of card, we remove them
-            self.top = self.left = self.bottom = self.right = None
-        setattr(self, location, card)
-        self._invalidate()
-
-    def rows(self, size, focus=False):
-        return self.display_widget(size, focus).rows(size, focus)
-
-    def render(self, size, focus=False):
-        return self.display_widget(size, focus).render(size, focus)
-
-    def display_widget(self, size, focus):
-        cards = {}
-        max_col, = size
-        separator = " - "
-        margin = max((max_col - Card.SIZE) / 2, 0) * " "
-        margin_center = max((max_col - Card.SIZE * 2 - len(separator)) / 2, 0) * " "
-        for location in ["top", "left", "bottom", "right"]:
-            card = getattr(self, location)
-            cards[location] = card.get_attr_text() if card else Card.SIZE * " "
-        render_wid = [
-            urwid.Text([margin, cards["top"]]),
-            urwid.Text([margin_center, cards["left"], separator, cards["right"]]),
-            urwid.Text([margin, cards["bottom"]]),
-        ]
-        return urwid.Pile(render_wid)
-
-
-class TarotGame(QuickTarotGame, urwid.WidgetWrap):
-    """Widget for card games"""
-
-    def __init__(self, parent, referee, players):
-        QuickTarotGame.__init__(self, parent, referee, players)
-        self.load_cards()
-        self.top = urwid.Pile([urwid.Padding(urwid.Text(self.top_nick), "center")])
-        # self.parent.host.debug()
-        self.table = Table()
-        self.center = urwid.Columns(
-            [
-                ("fixed", len(self.left_nick), urwid.Filler(urwid.Text(self.left_nick))),
-                urwid.Filler(self.table),
-                (
-                    "fixed",
-                    len(self.right_nick),
-                    urwid.Filler(urwid.Text(self.right_nick)),
-                ),
-            ]
-        )
-        """urwid.Pile([urwid.Padding(self.top_card_wid,'center'),
-                             urwid.Columns([('fixed',len(self.left_nick),urwid.Text(self.left_nick)),
-                                            urwid.Padding(self.center_cards_wid,'center'),
-                                            ('fixed',len(self.right_nick),urwid.Text(self.right_nick))
-                                           ]),
-                             urwid.Padding(self.bottom_card_wid,'center')
-                             ])"""
-        self.hand_wid = Hand(selectable=True, on_click=self.on_click)
-        self.main_frame = urwid.Frame(
-            self.center, header=self.top, footer=self.hand_wid, focus_part="footer"
-        )
-        urwid.WidgetWrap.__init__(self, self.main_frame)
-        self.parent.host.bridge.tarot_game_ready(
-            self.player_nick, referee, self.parent.profile
-        )
-
-    def load_cards(self):
-        """Load all the cards in memory"""
-        QuickTarotGame.load_cards(self)
-        for value in list(map(str, list(range(1, 22)))) + ["excuse"]:
-            card = Card("atout", value)
-            self.cards[card.suit, card.value] = card
-            self.deck.append(card)
-        for suit in ["pique", "coeur", "carreau", "trefle"]:
-            for value in list(map(str, list(range(1, 11)))) + ["valet", "cavalier", "dame", "roi"]:
-                card = Card(suit, value)
-                self.cards[card.suit, card.value] = card
-                self.deck.append(card)
-
-    def tarot_game_new_handler(self, hand):
-        """Start a new game, with given hand"""
-        if hand is []:  # reset the display after the scores have been showed
-            self.reset_round()
-            for location in ["top", "left", "bottom", "right"]:
-                self.table.put_card(location, None)
-            self.parent.host.redraw()
-            self.parent.host.bridge.tarot_game_ready(
-                self.player_nick, self.referee, self.parent.profile
-            )
-            return
-        QuickTarotGame.tarot_game_new_handler(self, hand)
-        self.hand_wid.update(self.hand)
-        self.parent.host.redraw()
-
-    def tarot_game_choose_contrat_handler(self, xml_data):
-        """Called when the player has to select his contrat
-        @param xml_data: SàT xml representation of the form"""
-        form = xmlui.create(
-            self.parent.host,
-            xml_data,
-            title=_("Please choose your contrat"),
-            flags=["NO_CANCEL"],
-            profile=self.parent.profile,
-        )
-        form.show(valign="top")
-
-    def tarot_game_show_cards_handler(self, game_stage, cards, data):
-        """Display cards in the middle of the game (to show for e.g. chien ou poignée)"""
-        QuickTarotGame.tarot_game_show_cards_handler(self, game_stage, cards, data)
-        self.center.widget_list[1] = urwid.Filler(Hand(self.to_show))
-        self.parent.host.redraw()
-
-    def tarot_game_your_turn_handler(self):
-        QuickTarotGame.tarot_game_your_turn_handler(self)
-
-    def tarot_game_score_handler(self, xml_data, winners, loosers):
-        """Called when the round is over, display the scores
-        @param xml_data: SàT xml representation of the form"""
-        if not winners and not loosers:
-            title = _("Draw game")
-        else:
-            title = _("You win \o/") if self.player_nick in winners else _("You loose :(")
-        form = xmlui.create(
-            self.parent.host,
-            xml_data,
-            title=title,
-            flags=["NO_CANCEL"],
-            profile=self.parent.profile,
-        )
-        form.show()
-
-    def tarot_game_invalid_cards_handler(self, phase, played_cards, invalid_cards):
-        """Invalid cards have been played
-        @param phase: phase of the game
-        @param played_cards: all the cards played
-        @param invalid_cards: cards which are invalid"""
-        QuickTarotGame.tarot_game_invalid_cards_handler(
-            self, phase, played_cards, invalid_cards
-        )
-        self.hand_wid.update(self.hand)
-        if self._autoplay == None:  # No dialog if there is autoplay
-            self.parent.host.bar_notify(_("Cards played are invalid !"))
-        self.parent.host.redraw()
-
-    def tarot_game_cards_played_handler(self, player, cards):
-        """A card has been played by player"""
-        QuickTarotGame.tarot_game_cards_played_handler(self, player, cards)
-        self.table.put_card(self.get_player_location(player), self.played[player])
-        self._checkState()
-        self.parent.host.redraw()
-
-    def _checkState(self):
-        if isinstance(
-            self.center.widget_list[1].original_widget, Hand
-        ):  # if we have a hand displayed
-            self.center.widget_list[1] = urwid.Filler(
-                self.table
-            )  # we show again the table
-            if self.state == "chien":
-                self.to_show = []
-                self.state = "wait"
-            elif self.state == "wait_for_ecart":
-                self.state = "ecart"
-                self.hand.extend(self.to_show)
-                self.hand.sort()
-                self.to_show = []
-                self.hand_wid.update(self.hand)
-
-    ##EVENTS##
-    def on_click(self, hand, card_wid):
-        """Called when user do an action on the hand"""
-        if not self.state in ["play", "ecart", "wait_for_ecart"]:
-            # it's not our turn, we ignore the click
-            card_wid.select(False)
-            return
-        self._checkState()
-        if self.state == "ecart":
-            if len(self.hand_wid.get_selected()) == 6:
-                pop_up_widget = sat_widgets.ConfirmDialog(
-                    _("Do you put these cards in chien ?"),
-                    yes_cb=self.on_ecart_done,
-                    no_cb=self.parent.host.remove_pop_up,
-                )
-                self.parent.host.show_pop_up(pop_up_widget)
-        elif self.state == "play":
-            card = card_wid.get_card()
-            self.parent.host.bridge.tarot_game_play_cards(
-                self.player_nick,
-                self.referee,
-                [(card.suit, card.value)],
-                self.parent.profile,
-            )
-            self.hand.remove(card)
-            self.hand_wid.update(self.hand)
-            self.state = "wait"
-
-    def on_ecart_done(self, button):
-        """Called when player has finished his écart"""
-        ecart = []
-        for card in self.hand_wid.get_selected():
-            ecart.append((card.suit, card.value))
-            self.hand.remove(card)
-        self.hand_wid.update(self.hand)
-        self.parent.host.bridge.tarot_game_play_cards(
-            self.player_nick, self.referee, ecart, self.parent.profile
-        )
-        self.state = "wait"
-        self.parent.host.remove_pop_up()
--- a/sat_frontends/primitivus/keys.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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/>.
-
-"""This file manage the action <=> key map"""
-
-from urwid_satext.keys import action_key_map
-
-
-action_key_map.update(
-    {
-        # Edit bar
-        ("edit", "MODE_INSERTION"): "i",
-        ("edit", "MODE_COMMAND"): ":",
-        ("edit", "HISTORY_PREV"): "up",
-        ("edit", "HISTORY_NEXT"): "down",
-        # global
-        ("global", "MENU_HIDE"): "meta m",
-        ("global", "NOTIFICATION_NEXT"): "ctrl n",
-        ("global", "OVERLAY_HIDE"): "ctrl s",
-        ("global", "DEBUG"): "ctrl d",
-        ("global", "CONTACTS_HIDE"): "f2",
-        (
-            "global",
-            "REFRESH_SCREEN",
-        ): "ctrl l",  # ctrl l is used by Urwid to refresh screen
-        # global menu
-        ("menu_global", "APP_QUIT"): "ctrl x",
-        ("menu_global", "ROOM_JOIN"): "meta j",
-        # primitivus widgets
-        ("primitivus_widget", "DECORATION_HIDE"): "meta l",
-        # contact list
-        ("contact_list", "STATUS_HIDE"): "meta s",
-        ("contact_list", "DISCONNECTED_HIDE"): "meta d",
-        ("contact_list", "RESOURCES_HIDE"): "meta r",
-        # chat panel
-        ("chat_panel", "OCCUPANTS_HIDE"): "meta p",
-        ("chat_panel", "TIMESTAMP_HIDE"): "meta t",
-        ("chat_panel", "SHORT_NICKNAME"): "meta n",
-        ("chat_panel", "SUBJECT_SWITCH"): "meta s",
-        ("chat_panel", "GOTO_BOTTOM"): "G",
-        # card game
-        ("card_game", "CARD_SELECT"): " ",
-        # focus
-        ("focus", "FOCUS_EXTRA"): "ctrl f",
-    }
-)
-
-
-action_key_map.set_close_namespaces(tuple(), ("global", "focus", "menu_global"))
-action_key_map.check_namespaces()
--- a/sat_frontends/primitivus/notify.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,92 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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/>.
-
-import dbus
-
-
-class Notify(object):
-    """Used to send notification and detect if we have focus"""
-
-    def __init__(self):
-
-        # X11 stuff
-        self.display = None
-        self.X11_id = -1
-
-        try:
-            from Xlib import display as X_display
-
-            self.display = X_display.Display()
-            self.X11_id = self.get_focus()
-        except:
-            pass
-
-        # Now we try to connect to Freedesktop D-Bus API
-        try:
-            bus = dbus.SessionBus()
-            db_object = bus.get_object(
-                "org.freedesktop.Notifications",
-                "/org/freedesktop/Notifications",
-                follow_name_owner_changes=True,
-            )
-            self.freedesktop_int = dbus.Interface(
-                db_object, dbus_interface="org.freedesktop.Notifications"
-            )
-        except:
-            self.freedesktop_int = None
-
-    def get_focus(self):
-        if not self.display:
-            return 0
-        return self.display.get_input_focus().focus.id
-
-    def has_focus(self):
-        return (self.get_focus() == self.X11_id) if self.display else True
-
-    def use_x11(self):
-        return bool(self.display)
-
-    def send_notification(self, summ_mess, body_mess=""):
-        """Send notification to the user if possible"""
-        # TODO: check options before sending notifications
-        if self.freedesktop_int:
-            self.send_fd_notification(summ_mess, body_mess)
-
-    def send_fd_notification(self, summ_mess, body_mess=""):
-        """Send notification with the FreeDesktop D-Bus API"""
-        if self.freedesktop_int:
-            app_name = "Primitivus"
-            replaces_id = 0
-            app_icon = ""
-            summary = summ_mess
-            body = body_mess
-            actions = dbus.Array(signature="s")
-            hints = dbus.Dictionary(signature="sv")
-            expire_timeout = -1
-
-            self.freedesktop_int.Notify(
-                app_name,
-                replaces_id,
-                app_icon,
-                summary,
-                body,
-                actions,
-                hints,
-                expire_timeout,
-            )
--- a/sat_frontends/primitivus/profile_manager.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,228 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-from libervia.backend.core import log as logging
-
-log = logging.getLogger(__name__)
-from sat_frontends.quick_frontend.quick_profile_manager import QuickProfileManager
-from sat_frontends.primitivus.constants import Const as C
-from sat_frontends.primitivus.keys import action_key_map as a_key
-from urwid_satext import sat_widgets
-import urwid
-
-
-class ProfileManager(QuickProfileManager, urwid.WidgetWrap):
-    def __init__(self, host, autoconnect=None):
-        QuickProfileManager.__init__(self, host, autoconnect)
-
-        # login & password box must be created before list because of on_profile_change
-        self.login_wid = sat_widgets.AdvancedEdit(_("Login:"), align="center")
-        self.pass_wid = sat_widgets.Password(_("Password:"), align="center")
-
-        style = ["no_first_select"]
-        profiles = host.bridge.profiles_list_get()
-        profiles.sort()
-        self.list_profile = sat_widgets.List(
-            profiles, style=style, align="center", on_change=self.on_profile_change
-        )
-
-        # new & delete buttons
-        buttons = [
-            urwid.Button(_("New"), self.on_new_profile),
-            urwid.Button(_("Delete"), self.on_delete_profile),
-        ]
-        buttons_flow = urwid.GridFlow(
-            buttons,
-            max([len(button.get_label()) for button in buttons]) + 4,
-            1,
-            1,
-            "center",
-        )
-
-        # second part: login information:
-        divider = urwid.Divider("-")
-
-        # connect button
-        connect_button = sat_widgets.CustomButton(
-            _("Connect"), self.on_connect_profiles, align="center"
-        )
-
-        # we now build the widget
-        list_walker = urwid.SimpleFocusListWalker(
-            [
-                buttons_flow,
-                self.list_profile,
-                divider,
-                self.login_wid,
-                self.pass_wid,
-                connect_button,
-            ]
-        )
-        frame_body = urwid.ListBox(list_walker)
-        frame = urwid.Frame(
-            frame_body,
-            urwid.AttrMap(urwid.Text(_("Profile Manager"), align="center"), "title"),
-        )
-        self.main_widget = urwid.LineBox(frame)
-        urwid.WidgetWrap.__init__(self, self.main_widget)
-
-        self.go(autoconnect)
-
-    def keypress(self, size, key):
-        if key == a_key["APP_QUIT"]:
-            self.host.on_exit()
-            raise urwid.ExitMainLoop()
-        elif key in (a_key["FOCUS_UP"], a_key["FOCUS_DOWN"]):
-            focus_diff = 1 if key == a_key["FOCUS_DOWN"] else -1
-            list_box = self.main_widget.base_widget.body
-            current_focus = list_box.body.get_focus()[1]
-            if current_focus is None:
-                return
-            while True:
-                current_focus += focus_diff
-                if current_focus < 0 or current_focus >= len(list_box.body):
-                    break
-                if list_box.body[current_focus].selectable():
-                    list_box.set_focus(
-                        current_focus, "above" if focus_diff == 1 else "below"
-                    )
-                    list_box._invalidate()
-                    return
-        return super(ProfileManager, self).keypress(size, key)
-
-    def cancel_dialog(self, button):
-        self.host.remove_pop_up()
-
-    def new_profile(self, button, edit):
-        """Create the profile"""
-        name = edit.get_edit_text()
-        self.host.bridge.profile_create(
-            name,
-            callback=lambda: self.new_profile_created(name),
-            errback=self.profile_creation_failure,
-        )
-
-    def new_profile_created(self, profile):
-        # new profile will be selected, and a selected profile assume the session is started
-        self.host.bridge.profile_start_session(
-            "",
-            profile,
-            callback=lambda __: self.new_profile_session_started(profile),
-            errback=self.profile_creation_failure,
-        )
-
-    def new_profile_session_started(self, profile):
-        self.host.remove_pop_up()
-        self.refill_profiles()
-        self.list_profile.select_value(profile)
-        self.current.profile = profile
-        self.get_connection_params(profile)
-        self.host.redraw()
-
-    def profile_creation_failure(self, reason):
-        self.host.remove_pop_up()
-        message = self._get_error_message(reason)
-        self.host.alert(_("Can't create profile"), message)
-
-    def delete_profile(self, button):
-        self._delete_profile()
-        self.host.remove_pop_up()
-
-    def on_new_profile(self, e):
-        pop_up_widget = sat_widgets.InputDialog(
-            _("New profile"),
-            _("Please enter a new profile name"),
-            cancel_cb=self.cancel_dialog,
-            ok_cb=self.new_profile,
-        )
-        self.host.show_pop_up(pop_up_widget)
-
-    def on_delete_profile(self, e):
-        if self.current.profile:
-            pop_up_widget = sat_widgets.ConfirmDialog(
-                _("Are you sure you want to delete the profile {} ?").format(
-                    self.current.profile
-                ),
-                no_cb=self.cancel_dialog,
-                yes_cb=self.delete_profile,
-            )
-            self.host.show_pop_up(pop_up_widget)
-
-    def on_connect_profiles(self, button):
-        """Connect the profiles and start the main widget
-
-        @param button: the connect button
-        """
-        self._on_connect_profiles()
-
-    def reset_fields(self):
-        """Set profile to None, and reset fields"""
-        super(ProfileManager, self).reset_fields()
-        self.list_profile.unselect_all(invisible=True)
-
-    def set_profiles(self, profiles):
-        """Update the list of profiles"""
-        self.list_profile.change_values(profiles)
-        self.host.redraw()
-
-    def get_profiles(self):
-        return self.list_profile.get_selected_values()
-
-    def get_jid(self):
-        return self.login_wid.get_edit_text()
-
-    def getPassword(self):
-        return self.pass_wid.get_edit_text()
-
-    def set_jid(self, jid_):
-        self.login_wid.set_edit_text(jid_)
-        self.current.login = jid_
-        self.host.redraw()  # FIXME: redraw should be avoided
-
-    def set_password(self, password):
-        self.pass_wid.set_edit_text(password)
-        self.current.password = password
-        self.host.redraw()
-
-    def on_profile_change(self, list_wid, widget=None, selected=None):
-        """This is called when a profile is selected in the profile list.
-
-        @param list_wid: the List widget who sent the event
-        """
-        self.update_connection_params()
-        focused = list_wid.focus
-        selected = focused.get_state() if focused is not None else False
-        if not selected:  # profile was just unselected
-            return
-        focused.set_state(
-            False, invisible=True
-        )  # we don't want the widget to be selected until we are sure we can access it
-
-        def authenticate_cb(data, cb_id, profile):
-            if C.bool(data.pop("validated", C.BOOL_FALSE)):
-                self.current.profile = profile
-                focused.set_state(True, invisible=True)
-                self.get_connection_params(profile)
-                self.host.redraw()
-            self.host.action_manager(data, callback=authenticate_cb, profile=profile)
-
-        self.host.action_launch(
-            C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, profile=focused.text
-        )
--- a/sat_frontends/primitivus/progress.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,101 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-import urwid
-from urwid_satext import sat_widgets
-from sat_frontends.quick_frontend import quick_widgets
-
-
-class Progress(urwid.WidgetWrap, quick_widgets.QuickWidget):
-    PROFILES_ALLOW_NONE = True
-
-    def __init__(self, host, target, profiles):
-        assert target is None and profiles is None
-        quick_widgets.QuickWidget.__init__(self, host, target)
-        self.host = host
-        self.progress_list = urwid.SimpleListWalker([])
-        self.progress_dict = {}
-        listbox = urwid.ListBox(self.progress_list)
-        buttons = []
-        buttons.append(sat_widgets.CustomButton(_("Clear progress list"), self._on_clear))
-        max_len = max([button.get_size() for button in buttons])
-        buttons_wid = urwid.GridFlow(buttons, max_len, 1, 0, "center")
-        main_wid = sat_widgets.FocusFrame(listbox, footer=buttons_wid)
-        urwid.WidgetWrap.__init__(self, main_wid)
-
-    def add(self, progress_id, message, profile):
-        mess_wid = urwid.Text(message)
-        progr_wid = urwid.ProgressBar("progress_normal", "progress_complete")
-        column = urwid.Columns([mess_wid, progr_wid])
-        self.progress_dict[(progress_id, profile)] = {
-            "full": column,
-            "progress": progr_wid,
-            "state": "init",
-        }
-        self.progress_list.append(column)
-        self.progress_cb(self.host.loop, (progress_id, message, profile))
-
-    def progress_cb(self, loop, data):
-        progress_id, message, profile = data
-        data = self.host.bridge.progress_get(progress_id, profile)
-        pbar = self.progress_dict[(progress_id, profile)]["progress"]
-        if data:
-            if self.progress_dict[(progress_id, profile)]["state"] == "init":
-                # first answer, we must construct the bar
-                self.progress_dict[(progress_id, profile)]["state"] = "progress"
-                pbar.done = float(data["size"])
-
-            pbar.set_completion(float(data["position"]))
-            self.update_not_bar()
-        else:
-            if self.progress_dict[(progress_id, profile)]["state"] == "progress":
-                self.progress_dict[(progress_id, profile)]["state"] = "done"
-                pbar.set_completion(pbar.done)
-                self.update_not_bar()
-                return
-
-        loop.set_alarm_in(0.2, self.progress_cb, (progress_id, message, profile))
-
-    def _remove_bar(self, progress_id, profile):
-        wid = self.progress_dict[(progress_id, profile)]["full"]
-        self.progress_list.remove(wid)
-        del (self.progress_dict[(progress_id, profile)])
-
-    def _on_clear(self, button):
-        to_remove = []
-        for progress_id, profile in self.progress_dict:
-            if self.progress_dict[(progress_id, profile)]["state"] == "done":
-                to_remove.append((progress_id, profile))
-        for progress_id, profile in to_remove:
-            self._remove_bar(progress_id, profile)
-        self.update_not_bar()
-
-    def update_not_bar(self):
-        if not self.progress_dict:
-            self.host.set_progress(None)
-            return
-        progress = 0
-        nb_bars = 0
-        for progress_id, profile in self.progress_dict:
-            pbar = self.progress_dict[(progress_id, profile)]["progress"]
-            progress += pbar.current / pbar.done * 100
-            nb_bars += 1
-        av_progress = progress / float(nb_bars)
-        self.host.set_progress(av_progress)
--- a/sat_frontends/primitivus/status.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,114 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 libervia.backend.core.i18n import _
-import urwid
-from urwid_satext import sat_widgets
-from sat_frontends.quick_frontend.constants import Const as commonConst
-from sat_frontends.primitivus.constants import Const as C
-
-
-class StatusBar(urwid.Columns):
-    def __init__(self, host):
-        self.host = host
-        self.presence = sat_widgets.ClickableText("")
-        status_prefix = urwid.Text("[")
-        status_suffix = urwid.Text("]")
-        self.status = sat_widgets.ClickableText("")
-        self.set_presence_status(C.PRESENCE_UNAVAILABLE, "")
-        urwid.Columns.__init__(
-            self,
-            [
-                ("weight", 1, self.presence),
-                ("weight", 1, status_prefix),
-                ("weight", 9, self.status),
-                ("weight", 1, status_suffix),
-            ],
-        )
-        urwid.connect_signal(self.presence, "click", self.on_presence_click)
-        urwid.connect_signal(self.status, "click", self.on_status_click)
-
-    def on_presence_click(self, sender=None):
-        if not self.host.bridge.is_connected(
-            self.host.current_profile
-        ):  # FIXME: manage multi-profiles
-            return
-        options = [commonConst.PRESENCE[presence] for presence in commonConst.PRESENCE]
-        list_widget = sat_widgets.GenericList(
-            options=options, option_type=sat_widgets.ClickableText, on_click=self.on_change
-        )
-        decorated = sat_widgets.LabelLine(
-            list_widget, sat_widgets.SurroundedText(_("Set your presence"))
-        )
-        self.host.show_pop_up(decorated)
-
-    def on_status_click(self, sender=None):
-        if not self.host.bridge.is_connected(
-            self.host.current_profile
-        ):  # FIXME: manage multi-profiles
-            return
-        pop_up_widget = sat_widgets.InputDialog(
-            _("Set your status"),
-            _("New status"),
-            default_txt=self.status.get_text(),
-            cancel_cb=lambda _: self.host.remove_pop_up(),
-            ok_cb=self.on_change,
-        )
-        self.host.show_pop_up(pop_up_widget)
-
-    def on_change(self, sender=None, user_data=None):
-        new_value = user_data.get_text()
-        previous = (
-            [key for key in C.PRESENCE if C.PRESENCE[key][0] == self.presence.get_text()][
-                0
-            ],
-            self.status.get_text(),
-        )
-        if isinstance(user_data, sat_widgets.ClickableText):
-            new = (
-                [
-                    key
-                    for key in commonConst.PRESENCE
-                    if commonConst.PRESENCE[key] == new_value
-                ][0],
-                previous[1],
-            )
-        elif isinstance(user_data, sat_widgets.AdvancedEdit):
-            new = (previous[0], new_value[0])
-        if new != previous:
-            statuses = {
-                C.PRESENCE_STATUSES_DEFAULT: new[1]
-            }  # FIXME: manage multilingual statuses
-            for (
-                profile
-            ) in (
-                self.host.profiles
-            ):  # FIXME: for now all the profiles share the same status
-                self.host.bridge.presence_set(
-                    show=new[0], statuses=statuses, profile_key=profile
-                )
-            self.set_presence_status(new[0], new[1])
-        self.host.remove_pop_up()
-
-    def set_presence_status(self, show, status):
-        show_icon, show_attr = C.PRESENCE.get(show)
-        self.presence.set_text(("show_normal", show_icon))
-        if status is not None:
-            self.status.set_text((show_attr, status))
-        self.host.redraw()
--- a/sat_frontends/primitivus/widget.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core import log as logging
-
-log = logging.getLogger(__name__)
-import urwid
-from urwid_satext import sat_widgets
-from sat_frontends.primitivus.keys import action_key_map as a_key
-
-
-class PrimitivusWidget(urwid.WidgetWrap):
-    """Base widget for Primitivus"""
-
-    def __init__(self, w, title=""):
-        self._title = title
-        self._title_dynamic = None
-        self._original_widget = w
-        urwid.WidgetWrap.__init__(self, self._get_decoration(w))
-
-    @property
-    def title(self):
-        """Text shown in title bar of the widget"""
-
-        # profiles currently managed by frontend
-        try:
-            all_profiles = self.host.profiles
-        except AttributeError:
-            all_profiles = []
-
-        # profiles managed by the widget
-        try:
-            profiles = self.profiles
-        except AttributeError:
-            try:
-                profiles = [self.profile]
-            except AttributeError:
-                profiles = []
-
-        title_elts = []
-        if self._title:
-            title_elts.append(self._title)
-        if self._title_dynamic:
-            title_elts.append(self._title_dynamic)
-        if len(all_profiles) > 1 and profiles:
-            title_elts.append("[{}]".format(", ".join(profiles)))
-        return sat_widgets.SurroundedText(" ".join(title_elts))
-
-    @title.setter
-    def title(self, value):
-        self._title = value
-        if self.decoration_visible:
-            self.show_decoration()
-
-    @property
-    def title_dynamic(self):
-        """Dynamic part of title"""
-        return self._title_dynamic
-
-    @title_dynamic.setter
-    def title_dynamic(self, value):
-        self._title_dynamic = value
-        if self.decoration_visible:
-            self.show_decoration()
-
-    @property
-    def decoration_visible(self):
-        """True if the decoration is visible"""
-        return isinstance(self._w, sat_widgets.LabelLine)
-
-    def keypress(self, size, key):
-        if key == a_key["DECORATION_HIDE"]:  # user wants to (un)hide widget decoration
-            show = not self.decoration_visible
-            self.show_decoration(show)
-        else:
-            return super(PrimitivusWidget, self).keypress(size, key)
-
-    def _get_decoration(self, widget):
-        return sat_widgets.LabelLine(widget, self.title)
-
-    def show_decoration(self, show=True):
-        """Show/Hide the decoration around the window"""
-        self._w = (
-            self._get_decoration(self._original_widget) if show else self._original_widget
-        )
-
-    def get_menu(self):
-        raise NotImplementedError
--- a/sat_frontends/primitivus/xmlui.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,528 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-import urwid
-import copy
-from libervia.backend.core import exceptions
-from urwid_satext import sat_widgets
-from urwid_satext import files_management
-from libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-from sat_frontends.primitivus.constants import Const as C
-from sat_frontends.primitivus.widget import PrimitivusWidget
-from sat_frontends.tools import xmlui
-
-
-class PrimitivusEvents(object):
-    """ Used to manage change event of Primitivus widgets """
-
-    def _event_callback(self, ctrl, *args, **kwargs):
-        """" Call xmlui callback and ignore any extra argument """
-        args[-1](ctrl)
-
-    def _xmlui_on_change(self, callback):
-        """ Call callback with widget as only argument """
-        urwid.connect_signal(self, "change", self._event_callback, callback)
-
-
-class PrimitivusEmptyWidget(xmlui.EmptyWidget, urwid.Text):
-    def __init__(self, _xmlui_parent):
-        urwid.Text.__init__(self, "")
-
-
-class PrimitivusTextWidget(xmlui.TextWidget, urwid.Text):
-    def __init__(self, _xmlui_parent, value, read_only=False):
-        urwid.Text.__init__(self, value)
-
-
-class PrimitivusLabelWidget(xmlui.LabelWidget, PrimitivusTextWidget):
-    def __init__(self, _xmlui_parent, value):
-        super(PrimitivusLabelWidget, self).__init__(_xmlui_parent, value + ": ")
-
-
-class PrimitivusJidWidget(xmlui.JidWidget, PrimitivusTextWidget):
-    pass
-
-
-class PrimitivusDividerWidget(xmlui.DividerWidget, urwid.Divider):
-    def __init__(self, _xmlui_parent, style="line"):
-        if style == "line":
-            div_char = "─"
-        elif style == "dot":
-            div_char = "·"
-        elif style == "dash":
-            div_char = "-"
-        elif style == "plain":
-            div_char = "█"
-        elif style == "blank":
-            div_char = " "
-        else:
-            log.warning(_("Unknown div_char"))
-            div_char = "─"
-
-        urwid.Divider.__init__(self, div_char)
-
-
-class PrimitivusStringWidget(
-    xmlui.StringWidget, sat_widgets.AdvancedEdit, PrimitivusEvents
-):
-    def __init__(self, _xmlui_parent, value, read_only=False):
-        sat_widgets.AdvancedEdit.__init__(self, edit_text=value)
-        self.read_only = read_only
-
-    def selectable(self):
-        if self.read_only:
-            return False
-        return super(PrimitivusStringWidget, self).selectable()
-
-    def _xmlui_set_value(self, value):
-        self.set_edit_text(value)
-
-    def _xmlui_get_value(self):
-        return self.get_edit_text()
-
-
-class PrimitivusJidInputWidget(xmlui.JidInputWidget, PrimitivusStringWidget):
-    pass
-
-
-class PrimitivusPasswordWidget(
-    xmlui.PasswordWidget, sat_widgets.Password, PrimitivusEvents
-):
-    def __init__(self, _xmlui_parent, value, read_only=False):
-        sat_widgets.Password.__init__(self, edit_text=value)
-        self.read_only = read_only
-
-    def selectable(self):
-        if self.read_only:
-            return False
-        return super(PrimitivusPasswordWidget, self).selectable()
-
-    def _xmlui_set_value(self, value):
-        self.set_edit_text(value)
-
-    def _xmlui_get_value(self):
-        return self.get_edit_text()
-
-
-class PrimitivusTextBoxWidget(
-    xmlui.TextBoxWidget, sat_widgets.AdvancedEdit, PrimitivusEvents
-):
-    def __init__(self, _xmlui_parent, value, read_only=False):
-        sat_widgets.AdvancedEdit.__init__(self, edit_text=value, multiline=True)
-        self.read_only = read_only
-
-    def selectable(self):
-        if self.read_only:
-            return False
-        return super(PrimitivusTextBoxWidget, self).selectable()
-
-    def _xmlui_set_value(self, value):
-        self.set_edit_text(value)
-
-    def _xmlui_get_value(self):
-        return self.get_edit_text()
-
-
-class PrimitivusBoolWidget(xmlui.BoolWidget, urwid.CheckBox, PrimitivusEvents):
-    def __init__(self, _xmlui_parent, state, read_only=False):
-        urwid.CheckBox.__init__(self, "", state=state)
-        self.read_only = read_only
-
-    def selectable(self):
-        if self.read_only:
-            return False
-        return super(PrimitivusBoolWidget, self).selectable()
-
-    def _xmlui_set_value(self, value):
-        self.set_state(value == "true")
-
-    def _xmlui_get_value(self):
-        return C.BOOL_TRUE if self.get_state() else C.BOOL_FALSE
-
-
-class PrimitivusIntWidget(xmlui.IntWidget, sat_widgets.AdvancedEdit, PrimitivusEvents):
-    def __init__(self, _xmlui_parent, value, read_only=False):
-        sat_widgets.AdvancedEdit.__init__(self, edit_text=value)
-        self.read_only = read_only
-
-    def selectable(self):
-        if self.read_only:
-            return False
-        return super(PrimitivusIntWidget, self).selectable()
-
-    def _xmlui_set_value(self, value):
-        self.set_edit_text(value)
-
-    def _xmlui_get_value(self):
-        return self.get_edit_text()
-
-
-class PrimitivusButtonWidget(
-    xmlui.ButtonWidget, sat_widgets.CustomButton, PrimitivusEvents
-):
-    def __init__(self, _xmlui_parent, value, click_callback):
-        sat_widgets.CustomButton.__init__(self, value, on_press=click_callback)
-
-    def _xmlui_on_click(self, callback):
-        urwid.connect_signal(self, "click", callback)
-
-
-class PrimitivusListWidget(xmlui.ListWidget, sat_widgets.List, PrimitivusEvents):
-    def __init__(self, _xmlui_parent, options, selected, flags):
-        sat_widgets.List.__init__(self, options=options, style=flags)
-        self._xmlui_select_values(selected)
-
-    def _xmlui_select_value(self, value):
-        return self.select_value(value)
-
-    def _xmlui_select_values(self, values):
-        return self.select_values(values)
-
-    def _xmlui_get_selected_values(self):
-        return [option.value for option in self.get_selected_values()]
-
-    def _xmlui_add_values(self, values, select=True):
-        current_values = self.get_all_values()
-        new_values = copy.deepcopy(current_values)
-        for value in values:
-            if value not in current_values:
-                new_values.append(value)
-        if select:
-            selected = self._xmlui_get_selected_values()
-        self.change_values(new_values)
-        if select:
-            for value in values:
-                if value not in selected:
-                    selected.append(value)
-            self._xmlui_select_values(selected)
-
-
-class PrimitivusJidsListWidget(xmlui.ListWidget, sat_widgets.List, PrimitivusEvents):
-    def __init__(self, _xmlui_parent, jids, styles):
-        sat_widgets.List.__init__(
-            self,
-            options=jids + [""],  # the empty field is here to add new jids if needed
-            option_type=lambda txt, align: sat_widgets.AdvancedEdit(
-                edit_text=txt, align=align
-            ),
-            on_change=self._on_change,
-        )
-        self.delete = 0
-
-    def _on_change(self, list_widget, jid_widget=None, text=None):
-        if jid_widget is not None:
-            if jid_widget != list_widget.contents[-1] and not text:
-                # if a field is empty, we delete the line (except for the last line)
-                list_widget.contents.remove(jid_widget)
-            elif jid_widget == list_widget.contents[-1] and text:
-                # we always want an empty field as last value to be able to add jids
-                list_widget.contents.append(sat_widgets.AdvancedEdit())
-
-    def _xmlui_get_selected_values(self):
-        # XXX: there is not selection in this list, so we return all non empty values
-        return [jid_ for jid_ in self.get_all_values() if jid_]
-
-
-class PrimitivusAdvancedListContainer(
-    xmlui.AdvancedListContainer, sat_widgets.TableContainer, PrimitivusEvents
-):
-    def __init__(self, _xmlui_parent, columns, selectable="no"):
-        options = {"ADAPT": ()}
-        if selectable != "no":
-            options["HIGHLIGHT"] = ()
-        sat_widgets.TableContainer.__init__(
-            self, columns=columns, options=options, row_selectable=selectable != "no"
-        )
-
-    def _xmlui_append(self, widget):
-        self.add_widget(widget)
-
-    def _xmlui_add_row(self, idx):
-        self.set_row_index(idx)
-
-    def _xmlui_get_selected_widgets(self):
-        return self.get_selected_widgets()
-
-    def _xmlui_get_selected_index(self):
-        return self.get_selected_index()
-
-    def _xmlui_on_select(self, callback):
-        """ Call callback with widget as only argument """
-        urwid.connect_signal(self, "click", self._event_callback, callback)
-
-
-class PrimitivusPairsContainer(xmlui.PairsContainer, sat_widgets.TableContainer):
-    def __init__(self, _xmlui_parent):
-        options = {"ADAPT": (0,), "HIGHLIGHT": (0,)}
-        if self._xmlui_main.type == "param":
-            options["FOCUS_ATTR"] = "param_selected"
-        sat_widgets.TableContainer.__init__(self, columns=2, options=options)
-
-    def _xmlui_append(self, widget):
-        if isinstance(widget, PrimitivusEmptyWidget):
-            # we don't want highlight on empty widgets
-            widget = urwid.AttrMap(widget, "default")
-        self.add_widget(widget)
-
-
-class PrimitivusLabelContainer(PrimitivusPairsContainer, xmlui.LabelContainer):
-    pass
-
-
-class PrimitivusTabsContainer(xmlui.TabsContainer, sat_widgets.TabsContainer):
-    def __init__(self, _xmlui_parent):
-        sat_widgets.TabsContainer.__init__(self)
-
-    def _xmlui_append(self, widget):
-        self.body.append(widget)
-
-    def _xmlui_add_tab(self, label, selected):
-        tab = PrimitivusVerticalContainer(None)
-        self.add_tab(label, tab, selected)
-        return tab
-
-
-class PrimitivusVerticalContainer(xmlui.VerticalContainer, urwid.ListBox):
-    BOX_HEIGHT = 5
-
-    def __init__(self, _xmlui_parent):
-        urwid.ListBox.__init__(self, urwid.SimpleListWalker([]))
-        self._last_size = None
-
-    def _xmlui_append(self, widget):
-        if "flow" not in widget.sizing():
-            widget = urwid.BoxAdapter(widget, self.BOX_HEIGHT)
-        self.body.append(widget)
-
-    def render(self, size, focus=False):
-        if size != self._last_size:
-            (maxcol, maxrow) = size
-            if self.body:
-                widget = self.body[0]
-                if isinstance(widget, urwid.BoxAdapter):
-                    widget.height = maxrow
-            self._last_size = size
-        return super(PrimitivusVerticalContainer, self).render(size, focus)
-
-
-### Dialogs ###
-
-
-class PrimitivusDialog(object):
-    def __init__(self, _xmlui_parent):
-        self.host = _xmlui_parent.host
-
-    def _xmlui_show(self):
-        self.host.show_pop_up(self)
-
-    def _xmlui_close(self):
-        self.host.remove_pop_up(self)
-
-
-class PrimitivusMessageDialog(PrimitivusDialog, xmlui.MessageDialog, sat_widgets.Alert):
-    def __init__(self, _xmlui_parent, title, message, level):
-        PrimitivusDialog.__init__(self, _xmlui_parent)
-        xmlui.MessageDialog.__init__(self, _xmlui_parent)
-        sat_widgets.Alert.__init__(
-            self, title, message, ok_cb=lambda __: self._xmlui_close()
-        )
-
-
-class PrimitivusNoteDialog(xmlui.NoteDialog, PrimitivusMessageDialog):
-    # TODO: separate NoteDialog
-    pass
-
-
-class PrimitivusConfirmDialog(
-    PrimitivusDialog, xmlui.ConfirmDialog, sat_widgets.ConfirmDialog
-):
-    def __init__(self, _xmlui_parent, title, message, level, buttons_set):
-        PrimitivusDialog.__init__(self, _xmlui_parent)
-        xmlui.ConfirmDialog.__init__(self, _xmlui_parent)
-        sat_widgets.ConfirmDialog.__init__(
-            self,
-            title,
-            message,
-            no_cb=lambda __: self._xmlui_cancelled(),
-            yes_cb=lambda __: self._xmlui_validated(),
-        )
-
-
-class PrimitivusFileDialog(
-    PrimitivusDialog, xmlui.FileDialog, files_management.FileDialog
-):
-    def __init__(self, _xmlui_parent, title, message, level, filetype):
-        # TODO: message is not managed yet
-        PrimitivusDialog.__init__(self, _xmlui_parent)
-        xmlui.FileDialog.__init__(self, _xmlui_parent)
-        style = []
-        if filetype == C.XMLUI_DATA_FILETYPE_DIR:
-            style.append("dir")
-        files_management.FileDialog.__init__(
-            self,
-            ok_cb=lambda path: self._xmlui_validated({"path": path}),
-            cancel_cb=lambda __: self._xmlui_cancelled(),
-            message=message,
-            title=title,
-            style=style,
-        )
-
-
-class GenericFactory(object):
-    def __getattr__(self, attr):
-        if attr.startswith("create"):
-            cls = globals()[
-                "Primitivus" + attr[6:]
-            ]  # XXX: we prefix with "Primitivus" to work around an Urwid bug, WidgetMeta in Urwid don't manage multiple inheritance with same names
-            return cls
-
-
-class WidgetFactory(GenericFactory):
-    def __getattr__(self, attr):
-        if attr.startswith("create"):
-            cls = GenericFactory.__getattr__(self, attr)
-            cls._xmlui_main = self._xmlui_main
-            return cls
-
-
-class XMLUIPanel(xmlui.XMLUIPanel, PrimitivusWidget):
-    widget_factory = WidgetFactory()
-
-    def __init__(
-        self,
-        host,
-        parsed_xml,
-        title=None,
-        flags=None,
-        callback=None,
-        ignore=None,
-        whitelist=None,
-        profile=C.PROF_KEY_NONE,
-    ):
-        self.widget_factory._xmlui_main = self
-        self._dest = None
-        xmlui.XMLUIPanel.__init__(
-            self,
-            host,
-            parsed_xml,
-            title=title,
-            flags=flags,
-            callback=callback,
-            ignore=ignore,
-            profile=profile,
-        )
-        PrimitivusWidget.__init__(self, self.main_cont, self.xmlui_title)
-
-
-    def _parse_childs(self, _xmlui_parent, current_node, wanted=("container",), data=None):
-        # Small hack to always have a VerticalContainer as main container in Primitivus.
-        # this used to be the default behaviour for all frontends, but now
-        # TabsContainer can also be the main container.
-        if _xmlui_parent is self:
-            node = current_node.childNodes[0]
-            if node.nodeName == "container" and node.getAttribute("type") == "tabs":
-                _xmlui_parent = self.widget_factory.createVerticalContainer(self)
-                self.main_cont = _xmlui_parent
-        return super(XMLUIPanel, self)._parse_childs(_xmlui_parent, current_node, wanted,
-                                                    data)
-
-
-    def construct_ui(self, parsed_dom):
-        def post_treat():
-            assert self.main_cont.body
-
-            if self.type in ("form", "popup"):
-                buttons = []
-                if self.type == "form":
-                    buttons.append(urwid.Button(_("Submit"), self.on_form_submitted))
-                    if not "NO_CANCEL" in self.flags:
-                        buttons.append(urwid.Button(_("Cancel"), self.on_form_cancelled))
-                else:
-                    buttons.append(
-                        urwid.Button(_("OK"), on_press=lambda __: self._xmlui_close())
-                    )
-                max_len = max([len(button.get_label()) for button in buttons])
-                grid_wid = urwid.GridFlow(buttons, max_len + 4, 1, 0, "center")
-                self.main_cont.body.append(grid_wid)
-            elif self.type == "param":
-                tabs_cont = self.main_cont.body[0].base_widget
-                assert isinstance(tabs_cont, sat_widgets.TabsContainer)
-                buttons = []
-                buttons.append(sat_widgets.CustomButton(_("Save"), self.on_save_params))
-                buttons.append(
-                    sat_widgets.CustomButton(
-                        _("Cancel"), lambda x: self.host.remove_window()
-                    )
-                )
-                max_len = max([button.get_size() for button in buttons])
-                grid_wid = urwid.GridFlow(buttons, max_len, 1, 0, "center")
-                tabs_cont.add_footer(grid_wid)
-
-        xmlui.XMLUIPanel.construct_ui(self, parsed_dom, post_treat)
-        urwid.WidgetWrap.__init__(self, self.main_cont)
-
-    def show(self, show_type=None, valign="middle"):
-        """Show the constructed UI
-        @param show_type: how to show the UI:
-            - None (follow XMLUI's recommendation)
-            - 'popup'
-            - 'window'
-        @param valign: vertical alignment when show_type is 'popup'.
-            Ignored when show_type is 'window'.
-
-        """
-        if show_type is None:
-            if self.type in ("window", "param"):
-                show_type = "window"
-            elif self.type in ("popup", "form"):
-                show_type = "popup"
-
-        if show_type not in ("popup", "window"):
-            raise ValueError("Invalid show_type [%s]" % show_type)
-
-        self._dest = show_type
-        if show_type == "popup":
-            self.host.show_pop_up(self, valign=valign)
-        elif show_type == "window":
-            self.host.new_widget(self, user_action=self.user_action)
-        else:
-            assert False
-        self.host.redraw()
-
-    def _xmlui_close(self):
-        if self._dest == "window":
-            self.host.remove_window()
-        elif self._dest == "popup":
-            self.host.remove_pop_up(self)
-        else:
-            raise exceptions.InternalError(
-                "self._dest unknown, are you sure you have called XMLUI.show ?"
-            )
-
-
-class XMLUIDialog(xmlui.XMLUIDialog):
-    dialog_factory = GenericFactory()
-
-
-xmlui.register_class(xmlui.CLASS_PANEL, XMLUIPanel)
-xmlui.register_class(xmlui.CLASS_DIALOG, XMLUIDialog)
-create = xmlui.create
--- a/sat_frontends/quick_frontend/constants.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,120 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core import constants
-from libervia.backend.core.i18n import _
-from collections import OrderedDict  # only available from python 2.7
-
-
-class Const(constants.Const):
-
-    PRESENCE = OrderedDict(
-        [
-            ("", _("Online")),
-            ("chat", _("Free for chat")),
-            ("away", _("Away from keyboard")),
-            ("dnd", _("Do not disturb")),
-            ("xa", _("Extended away")),
-        ]
-    )
-
-    # from plugin_misc_text_syntaxes
-    SYNTAX_XHTML = "XHTML"
-    SYNTAX_CURRENT = "@CURRENT@"
-    SYNTAX_TEXT = "text"
-
-    # XMLUI
-    SAT_FORM_PREFIX = "SAT_FORM_"
-    SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_"  # used to have unique elements names
-    XMLUI_STATUS_VALIDATED = "validated"
-    XMLUI_STATUS_CANCELLED = constants.Const.XMLUI_DATA_CANCELLED
-
-    # Roster
-    CONTACT_GROUPS = "groups"
-    CONTACT_RESOURCES = "resources"
-    CONTACT_MAIN_RESOURCE = "main_resource"
-    CONTACT_SPECIAL = "special"
-    CONTACT_SPECIAL_GROUP = "group"  # group chat special entity
-    CONTACT_SELECTED = "selected"
-    # used in handler to track where the contact is coming from
-    CONTACT_PROFILE = "profile"
-    CONTACT_SPECIAL_ALLOWED = (CONTACT_SPECIAL_GROUP,)  # allowed values for special flag
-    # set of forbidden names for contact data
-    CONTACT_DATA_FORBIDDEN = {
-        CONTACT_GROUPS,
-        CONTACT_RESOURCES,
-        CONTACT_MAIN_RESOURCE,
-        CONTACT_SELECTED,
-        CONTACT_PROFILE,
-    }
-
-    # Chats
-    CHAT_STATE_ICON = {
-        "": " ",
-        "active": "✔",
-        "inactive": "☄",
-        "gone": "✈",
-        "composing": "✎",
-        "paused": "…",
-    }
-
-    # Blogs
-    ENTRY_MODE_TEXT = "text"
-    ENTRY_MODE_RICH = "rich"
-    ENTRY_MODE_XHTML = "xhtml"
-
-    # Widgets management
-    # FIXME: should be in quick_frontend.constant, but Libervia doesn't inherit from it
-    WIDGET_NEW = "NEW"
-    WIDGET_KEEP = "KEEP"
-    WIDGET_RAISE = "RAISE"
-    WIDGET_RECREATE = "RECREATE"
-
-    # Updates (generic)
-    UPDATE_DELETE = "DELETE"
-    UPDATE_MODIFY = "MODIFY"
-    UPDATE_ADD = "ADD"
-    UPDATE_SELECTION = "SELECTION"
-    # high level update (i.e. not item level but organisation of items)
-    UPDATE_STRUCTURE = "STRUCTURE"
-
-    LISTENERS = {
-        "avatar",
-        "nicknames",
-        "presence",
-        "selected",
-        "notification",
-        "notificationsClear",
-        "widgetNew",
-        "widgetDeleted",
-        "profile_plugged",
-        "contactsFilled",
-        "disconnect",
-        "gotMenus",
-        "menu",
-        "progress_finished",
-        "progress_error",
-    }
-
-    # Notifications
-    NOTIFY_MESSAGE = "MESSAGE"  # a message has been received
-    NOTIFY_MENTION = "MENTION"  # user has been mentionned
-    NOTIFY_PROGRESS_END = "PROGRESS_END"  # a progression has finised
-    NOTIFY_GENERIC = "GENERIC"  # a notification which has not its own type
-    NOTIFY_ALL = (NOTIFY_MESSAGE, NOTIFY_MENTION, NOTIFY_PROGRESS_END, NOTIFY_GENERIC)
--- a/sat_frontends/quick_frontend/quick_app.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1387 +0,0 @@
-#!/usr/bin/env python3
-
-# helper class for making a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.log import getLogger
-from libervia.backend.core.i18n import _
-from libervia.backend.core import exceptions
-from libervia.backend.tools import trigger
-from libervia.backend.tools.common import data_format
-
-from sat_frontends.tools import jid
-from sat_frontends.quick_frontend import quick_widgets
-from sat_frontends.quick_frontend import quick_menus
-from sat_frontends.quick_frontend import quick_blog
-from sat_frontends.quick_frontend import quick_chat, quick_games
-from sat_frontends.quick_frontend import quick_contact_list
-from sat_frontends.quick_frontend.constants import Const as C
-
-import sys
-import time
-
-
-log = getLogger(__name__)
-
-
-class ProfileManager(object):
-    """Class managing all data relative to one profile, and plugging in mechanism"""
-
-    # TODO: handle waiting XMLUI requests: getWaitingConf doesn't exist anymore
-    #       and a way to keep some XMLUI request between sessions is expected in backend
-    host = None
-    bridge = None
-    cache_keys_to_get = ['avatar', 'nicknames']
-
-    def __init__(self, profile):
-        self.profile = profile
-        self.connected = False
-        self.whoami = None
-        self.notifications = {}  # key: bare jid or '' for general, value: notif data
-
-    @property
-    def autodisconnect(self):
-        try:
-            autodisconnect = self._autodisconnect
-        except AttributeError:
-            autodisconnect = False
-        return autodisconnect
-
-    def plug(self):
-        """Plug the profile to the host"""
-        # first of all we create the contact lists
-        self.host.contact_lists.add_profile(self.profile)
-
-        # we get the essential params
-        self.bridge.param_get_a_async(
-            "JabberID",
-            "Connection",
-            profile_key=self.profile,
-            callback=self._plug_profile_jid,
-            errback=self._get_param_error,
-        )
-
-    def _plug_profile_jid(self, jid_s):
-        self.whoami = jid.JID(jid_s)  # resource might change after the connection
-        log.info(f"Our current jid is: {self.whoami}")
-        self.bridge.is_connected(self.profile, callback=self._plug_profile_isconnected)
-
-    def _autodisconnect_eb(self, failure_):
-        # XXX: we ignore error on this parameter, as Libervia can't access it
-        log.warning(
-            _("Error while trying to get autodisconnect param, ignoring: {}").format(
-                failure_
-            )
-        )
-        self._plug_profile_autodisconnect("false")
-
-    def _plug_profile_isconnected(self, connected):
-        self.connected = connected
-        if connected:
-            self.host.profile_connected(self.profile)
-        self.bridge.param_get_a_async(
-            "autodisconnect",
-            "Connection",
-            profile_key=self.profile,
-            callback=self._plug_profile_autodisconnect,
-            errback=self._autodisconnect_eb,
-        )
-
-    def _plug_profile_autodisconnect(self, autodisconnect):
-        if C.bool(autodisconnect):
-            self._autodisconnect = True
-        self.bridge.param_get_a_async(
-            "autoconnect",
-            "Connection",
-            profile_key=self.profile,
-            callback=self._plug_profile_autoconnect,
-            errback=self._get_param_error,
-        )
-
-    def _plug_profile_autoconnect(self, value_str):
-        autoconnect = C.bool(value_str)
-        if autoconnect and not self.connected:
-            self.host.connect(
-                self.profile, callback=lambda __: self._plug_profile_afterconnect()
-            )
-        else:
-            self._plug_profile_afterconnect()
-
-    def _plug_profile_afterconnect(self):
-        # Profile can be connected or not
-        # we get cached data
-        self.connected = True
-        self.host.bridge.features_get(
-            profile_key=self.profile,
-            callback=self._plug_profile_get_features_cb,
-            errback=self._plug_profile_get_features_eb,
-        )
-
-    def _plug_profile_get_features_eb(self, failure):
-        log.error("Couldn't get features: {}".format(failure))
-        self._plug_profile_get_features_cb({})
-
-    def _plug_profile_get_features_cb(self, features):
-        self.host.features = features
-        self.host.bridge.entities_data_get([], ProfileManager.cache_keys_to_get,
-                                         profile=self.profile,
-                                         callback=self._plug_profile_got_cached_values,
-                                         errback=self._plug_profile_failed_cached_values)
-
-    def _plug_profile_failed_cached_values(self, failure):
-        log.error("Couldn't get cached values: {}".format(failure))
-        self._plug_profile_got_cached_values({})
-
-    def _plug_profile_got_cached_values(self, cached_values):
-        contact_list = self.host.contact_lists[self.profile]
-        # add the contact list and its listener
-        for entity_s, data in cached_values.items():
-            for key, value in data.items():
-                self.host.entity_data_updated_handler(entity_s, key, value, self.profile)
-
-        if not self.connected:
-            self.host.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=self.profile)
-        else:
-
-            contact_list.fill()
-            self.host.set_presence_status(profile=self.profile)
-
-            # The waiting subscription requests
-            self.bridge.sub_waiting_get(
-                self.profile, callback=self._plug_profile_got_waiting_sub
-            )
-
-    def _plug_profile_got_waiting_sub(self, waiting_sub):
-        for sub in waiting_sub:
-            self.host.subscribe_handler(waiting_sub[sub], sub, self.profile)
-
-        self.bridge.muc_get_rooms_joined(
-            self.profile, callback=self._plug_profile_got_rooms_joined
-        )
-
-    def _plug_profile_got_rooms_joined(self, rooms_args):
-        # Now we open the MUC window where we already are:
-        for room_args in rooms_args:
-            self.host.muc_room_joined_handler(*room_args, profile=self.profile)
-        # Presence must be requested after rooms are filled
-        self.host.bridge.presence_statuses_get(
-            self.profile, callback=self._plug_profile_got_presences
-        )
-
-    def _plug_profile_got_presences(self, presences):
-        for contact in presences:
-            for res in presences[contact]:
-                jabber_id = ("%s/%s" % (jid.JID(contact).bare, res)) if res else contact
-                show = presences[contact][res][0]
-                priority = presences[contact][res][1]
-                statuses = presences[contact][res][2]
-                self.host.presence_update_handler(
-                    jabber_id, show, priority, statuses, self.profile
-                )
-
-        # At this point, profile should be fully plugged
-        # and we launch frontend specific method
-        self.host.profile_plugged(self.profile)
-
-    def _get_param_error(self, failure):
-        log.error(_("Can't get profile parameter: {msg}").format(msg=failure))
-
-
-class ProfilesManager(object):
-    """Class managing collection of profiles"""
-
-    def __init__(self):
-        self._profiles = {}
-
-    def __contains__(self, profile):
-        return profile in self._profiles
-
-    def __iter__(self):
-        return iter(self._profiles.keys())
-
-    def __getitem__(self, profile):
-        return self._profiles[profile]
-
-    def __len__(self):
-        return len(self._profiles)
-
-    def items(self):
-        return self._profiles.items()
-
-    def values(self):
-        return self._profiles.values()
-
-    def plug(self, profile):
-        if profile in self._profiles:
-            raise exceptions.ConflictError(
-                "A profile of the name [{}] is already plugged".format(profile)
-            )
-        self._profiles[profile] = ProfileManager(profile)
-        self._profiles[profile].plug()
-
-    def unplug(self, profile):
-        if profile not in self._profiles:
-            raise ValueError("The profile [{}] is not plugged".format(profile))
-
-        # remove the contact list and its listener
-        host = self._profiles[profile].host
-        host.contact_lists[profile].unplug()
-
-        del self._profiles[profile]
-
-    def choose_one_profile(self):
-        return list(self._profiles.keys())[0]
-
-
-class QuickApp(object):
-    """This class contain the main methods needed for the frontend"""
-
-    MB_HANDLER = True  #: Set to False if the frontend doesn't manage microblog
-    AVATARS_HANDLER = True  #: set to False if avatars are not used
-    ENCRYPTION_HANDLERS = True  #: set to False if encryption is handled separatly
-    #: if True, QuickApp will call resync itself, on all widgets at the same time
-    #: if False, frontend must call resync itself when suitable (e.g. widget is being
-    #: visible)
-    AUTO_RESYNC = True
-
-    def __init__(self, bridge_factory, xmlui, check_options=None, connect_bridge=True):
-        """Create a frontend application
-
-        @param bridge_factory: method to use to create the bridge
-        @param xmlui: xmlui module
-        @param check_options: method to call to check options (usually command line
-            arguments)
-        """
-        self.xmlui = xmlui
-        self.menus = quick_menus.QuickMenusManager(self)
-        ProfileManager.host = self
-        self.profiles = ProfilesManager()
-        # profiles currently being plugged, used to (un)lock contact list updates
-        self._plugs_in_progress = set()
-        self.ready_profiles = set()  # profiles which are connected and ready
-        self.signals_cache = {}  # used to keep signal received between start of
-                                 # plug_profile and when the profile is actualy ready
-        self.contact_lists = quick_contact_list.QuickContactListHandler(self)
-        self.widgets = quick_widgets.QuickWidgetsManager(self)
-        if check_options is not None:
-            self.options = check_options()
-        else:
-            self.options = None
-
-        # see selected_widget setter and getter
-        self._selected_widget = None
-
-        # listeners are callable watching events
-        self._listeners = {}  # key: listener type ("avatar", "selected", etc),
-                              # value: list of callbacks
-
-        # triggers
-        self.trigger = (
-            trigger.TriggerManager()
-        )  # trigger are used to change the default behaviour
-
-        ## bridge ##
-        self.bridge = bridge_factory()
-        ProfileManager.bridge = self.bridge
-        if connect_bridge:
-            self.connect_bridge()
-
-        # frontend notifications
-        self._notif_id = 0
-        self._notifications = {}
-        # watched progresses and associated callbacks
-        self._progress_ids = {}
-        # available features
-        # FIXME: features are profile specific, to be checked
-        self.features = None
-        #: map of short name to namespaces
-        self.ns_map = {}
-        #: available encryptions
-        self.encryption_plugins = []
-        # state of synchronisation with backend
-        self._sync = True
-
-    def connect_bridge(self):
-        self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb)
-
-    def _namespaces_get_cb(self, ns_map):
-        self.ns_map = ns_map
-
-    def _namespaces_get_eb(self, failure_):
-        log.error(_("Can't get namespaces map: {msg}").format(msg=failure_))
-
-    def _encryption_plugins_get_cb(self, plugins_ser):
-        self.encryption_plugins = data_format.deserialise(plugins_ser, type_check=list)
-
-    def _encryption_plugins_get_eb(self, failure_):
-        log.warning(_("Can't retrieve encryption plugins: {msg}").format(msg=failure_))
-
-    def on_bridge_connected(self):
-        self.bridge.ready_get(self.on_backend_ready)
-
-    def _bridge_cb(self):
-        self.register_signal("connected")
-        self.register_signal("disconnected")
-        self.register_signal("action_new")
-        self.register_signal("contact_new")
-        self.register_signal("message_new")
-        if self.ENCRYPTION_HANDLERS:
-            self.register_signal("message_encryption_started")
-            self.register_signal("message_encryption_stopped")
-        self.register_signal("presence_update")
-        self.register_signal("subscribe")
-        self.register_signal("param_update")
-        self.register_signal("contact_deleted")
-        self.register_signal("entity_data_updated")
-        self.register_signal("progress_started")
-        self.register_signal("progress_finished")
-        self.register_signal("progress_error")
-        self.register_signal("muc_room_joined", iface="plugin")
-        self.register_signal("muc_room_left", iface="plugin")
-        self.register_signal("muc_room_user_changed_nick", iface="plugin")
-        self.register_signal("muc_room_new_subject", iface="plugin")
-        self.register_signal("chat_state_received", iface="plugin")
-        self.register_signal("message_state", iface="plugin")
-        self.register_signal("ps_event", iface="plugin")
-        # useful for debugging
-        self.register_signal("_debug", iface="core")
-
-        # FIXME: do it dynamically
-        quick_games.Tarot.register_signals(self)
-        quick_games.Quiz.register_signals(self)
-        quick_games.Radiocol.register_signals(self)
-        self.on_bridge_connected()
-
-    def _bridge_eb(self, failure):
-        if isinstance(failure, exceptions.BridgeExceptionNoService):
-            print((_("Can't connect to SàT backend, are you sure it's launched ?")))
-            sys.exit(C.EXIT_BACKEND_NOT_FOUND)
-        elif isinstance(failure, exceptions.BridgeInitError):
-            print((_("Can't init bridge")))
-            sys.exit(C.EXIT_BRIDGE_ERROR)
-        else:
-            print((_("Error while initialising bridge: {}".format(failure))))
-
-    def on_backend_ready(self):
-        log.info("backend is ready")
-        self.bridge.namespaces_get(
-            callback=self._namespaces_get_cb, errback=self._namespaces_get_eb)
-        # we cache available encryption plugins, as we'll use them on each
-        # new chat widget
-        self.bridge.encryption_plugins_get(
-            callback=self._encryption_plugins_get_cb,
-            errback=self._encryption_plugins_get_eb)
-
-
-    @property
-    def current_profile(self):
-        """Profile that a user would expect to use"""
-        try:
-            return self.selected_widget.profile
-        except (TypeError, AttributeError):
-            return self.profiles.choose_one_profile()
-
-    @property
-    def visible_widgets(self):
-        """Widgets currently visible
-
-        This must be implemented by frontend
-        @return (iter[object]): iterable on visible widgets
-            widgets can be QuickWidgets or not
-        """
-        raise NotImplementedError
-
-    @property
-    def visible_quick_widgets(self):
-        """QuickWidgets currently visible
-
-        This generator iterate only on QuickWidgets, discarding other kinds of
-        widget the frontend may have.
-        @return (iter[object]): iterable on visible widgets
-        """
-        for w in self.visisble_widgets:
-            if isinstance(w, quick_widgets.QuickWidget):
-                return w
-
-    @property
-    def selected_widget(self):
-        """widget currently selected
-
-        This must be set by frontend using setter.
-        """
-        return self._selected_widget
-
-    @selected_widget.setter
-    def selected_widget(self, wid):
-        """Set the currently selected widget
-
-        Must be set by frontend
-        """
-        if self._selected_widget == wid:
-            return
-        self._selected_widget = wid
-        try:
-            on_selected = wid.on_selected
-        except AttributeError:
-            pass
-        else:
-            on_selected()
-
-        self.call_listeners("selected", wid)
-
-    # backend state management
-
-    @property
-    def sync(self):
-        """Synchronization flag
-
-        True if this frontend is synchronised with backend
-        """
-        return self._sync
-
-    @sync.setter
-    def sync(self, state):
-        """Called when backend is desynchronised or resynchronising
-
-        @param state(bool): True: if the backend is resynchronising
-            False when we lose synchronisation, for instance if frontend is going to sleep
-            or if connection has been lost and a reconnection is needed
-        """
-        if state:
-            log.debug("we are synchronised with server")
-            if self.AUTO_RESYNC:
-                # we are resynchronising all widgets
-                log.debug("doing a full widgets resynchronisation")
-                for w in self.widgets:
-                    try:
-                        resync = w.resync
-                    except AttributeError:
-                        pass
-                    else:
-                        resync()
-                self.contact_lists.fill()
-
-            self._sync = state
-        else:
-            log.debug("we have lost synchronisation with server")
-            self._sync = state
-            # we've lost synchronisation, all widgets must be notified
-            # note: this is always called independently of AUTO_RESYNC
-            for w in self.widgets:
-                try:
-                    w.sync = False
-                except AttributeError:
-                    pass
-
-    def register_signal(
-        self, function_name, handler=None, iface="core", with_profile=True
-    ):
-        """Register a handler for a signal
-
-        @param function_name (str): name of the signal to handle
-        @param handler (instancemethod): method to call when the signal arrive,
-            None for calling an automatically named handler (function_name + 'Handler')
-        @param iface (str): interface of the bridge to use ('core' or 'plugin')
-        @param with_profile (boolean): True if the signal concerns a specific profile,
-            in that case the profile name has to be passed by the caller
-        """
-        log.debug("registering signal {name}".format(name=function_name))
-        if handler is None:
-            handler = getattr(self, "{}{}".format(function_name, "_handler"))
-        if not with_profile:
-            self.bridge.register_signal(function_name, handler, iface)
-            return
-
-        def signal_received(*args, **kwargs):
-            profile = kwargs.get("profile")
-            if profile is None:
-                if not args:
-                    raise exceptions.ProfileNotSetError
-                profile = args[-1]
-            if profile is not None:
-                if not self.check_profile(profile):
-                    if profile in self.profiles:
-                        # profile is not ready but is in self.profiles, that's mean that
-                        # it's being connecting and we need to cache the signal
-                        self.signals_cache.setdefault(profile, []).append(
-                            (function_name, handler, args, kwargs)
-                        )
-                    return  # we ignore signal for profiles we don't manage
-            handler(*args, **kwargs)
-
-        self.bridge.register_signal(function_name, signal_received, iface)
-
-    def addListener(self, type_, callback, profiles_filter=None):
-        """Add a listener for an event
-
-        /!\ don't forget to remove listener when not used anymore (e.g. if you delete a
-            widget)
-        @param type_: type of event, can be:
-            - contactsFilled: called when contact have been fully filled for a profiles
-                kwargs: profile
-            - avatar: called when avatar data is updated
-                args: (entity, avatar_data, profile)
-            - nicknames: called when nicknames data is updated
-                args: (entity, nicknames, profile)
-            - presence: called when a presence is received
-                args: (entity, show, priority, statuses, profile)
-            - selected: called when a widget is selected
-                args: (selected_widget,)
-            - notification: called when a new notification is emited
-                args: (entity, notification_data, profile)
-            - notificationsClear: called when notifications are cleared
-                args: (entity, type_, profile)
-            - widgetNew: a new QuickWidget has been created
-                args: (widget,)
-            - widgetDeleted: all instances of a widget with specific hash have been
-                deleted
-                args: (widget_deleted,)
-            - menu: called when a menu item is added or removed
-                args: (type_, path, path_i18n, item) were values are:
-                    type_: same as in [sat.core.sat_main.SAT.import_menu]
-                    path: same as in [sat.core.sat_main.SAT.import_menu]
-                    path_i18n: translated path (or None if the item is removed)
-                    item: instance of quick_menus.MenuItemBase or None if the item is
-                          removed
-            - gotMenus: called only once when menu are available (no arg)
-            - progress_finished: called when a progressing action has just finished
-                args:  (progress_id, metadata, profile)
-            - progress_error: called when a progressing action failed
-                args: (progress_id, error_msg, profile):
-        @param callback: method to call on event
-        @param profiles_filter (set[unicode]): if set and not empty, the
-            listener will be callable only by one of the given profiles.
-        """
-        assert type_ in C.LISTENERS
-        self._listeners.setdefault(type_, {})[callback] = profiles_filter
-
-    def removeListener(self, type_, callback, ignore_missing=False):
-        """Remove a callback from listeners
-
-        @param type_(str): same as for [addListener]
-        @param callback(callable): callback to remove
-        @param ignore_missing(bool): if True, don't log error if the listener doesn't
-            exist
-        """
-        assert type_ in C.LISTENERS
-        try:
-            self._listeners[type_].pop(callback)
-        except KeyError:
-            if not ignore_missing:
-                log.error(
-                    f"Trying to remove an inexisting listener (type = {type_}): "
-                    f"{callback}")
-
-    def call_listeners(self, type_, *args, **kwargs):
-        """Call the methods which listen type_ event. If a profiles filter has
-        been register with a listener and profile argument is not None, the
-        listener will be called only if profile is in the profiles filter list.
-
-        @param type_: same as for [addListener]
-        @param *args: arguments sent to callback
-        @param **kwargs: keywords argument, mainly used to pass "profile" when needed
-        """
-        assert type_ in C.LISTENERS
-        try:
-            listeners = self._listeners[type_]
-        except KeyError:
-            pass
-        else:
-            profile = kwargs.get("profile")
-            for listener, profiles_filter in list(listeners.items()):
-                if profile is None or not profiles_filter or profile in profiles_filter:
-                    listener(*args, **kwargs)
-
-    def check_profile(self, profile):
-        """Tell if the profile is currently followed by the application, and ready"""
-        return profile in self.ready_profiles
-
-    def post_init(self, profile_manager):
-        """Must be called after initialization is done, do all automatic task
-
-        (auto plug profile)
-        @param profile_manager: instance of a subclass of
-            Quick_frontend.QuickProfileManager
-        """
-        if self.options and self.options.profile:
-            profile_manager.autoconnect([self.options.profile])
-
-    def profile_plugged(self, profile):
-        """Method called when the profile is fully plugged
-
-        This will launch frontend specific workflow
-
-        /!\ if you override the method and don't call the parent, be sure to add the
-            profile to ready_profiles ! if you don't, all signals will stay in cache
-
-        @param profile(unicode): %(doc_profile)s
-        """
-        self._plugs_in_progress.remove(profile)
-        self.ready_profiles.add(profile)
-
-        # profile is ready, we can call send signals that where is cache
-        cached_signals = self.signals_cache.pop(profile, [])
-        for function_name, handler, args, kwargs in cached_signals:
-            log.debug(
-                "Calling cached signal [%s] with args %s and kwargs %s"
-                % (function_name, args, kwargs)
-            )
-            handler(*args, **kwargs)
-
-        self.call_listeners("profile_plugged", profile=profile)
-        if not self._plugs_in_progress:
-            self.contact_lists.lock_update(False)
-
-    def profile_connected(self, profile):
-        """Called when a plugged profile is connected
-
-        it is called independently of profile_plugged (may be called before or after
-        profile_plugged)
-        """
-        pass
-
-    def connect(self, profile, callback=None, errback=None):
-        if not callback:
-            callback = lambda __: None
-        if not errback:
-
-            def errback(failure):
-                log.error(_("Can't connect profile [%s]") % failure)
-                try:
-                    module = failure.module
-                except AttributeError:
-                    module = ""
-                try:
-                    message = failure.message
-                except AttributeError:
-                    message = "error"
-                try:
-                    fullname = failure.fullname
-                except AttributeError:
-                    fullname = "error"
-                if (
-                    module.startswith("twisted.words.protocols.jabber")
-                    and failure.condition == "not-authorized"
-                ):
-                    self.action_launch(C.CHANGE_XMPP_PASSWD_ID, {}, profile=profile)
-                else:
-                    self.show_dialog(message, fullname, "error")
-
-        self.bridge.connect(profile, callback=callback, errback=errback)
-
-    def plug_profiles(self, profiles):
-        """Tell application which profiles must be used
-
-        @param profiles: list of valid profile names
-        """
-        self.contact_lists.lock_update()
-        self._plugs_in_progress.update(profiles)
-        self.plugging_profiles()
-        for profile in profiles:
-            self.profiles.plug(profile)
-
-    def plugging_profiles(self):
-        """Method to subclass to manage frontend specific things to do
-
-        will be called when profiles are choosen and are to be plugged soon
-        """
-        pass
-
-    def unplug_profile(self, profile):
-        """Tell the application to not follow anymore the profile"""
-        if not profile in self.profiles:
-            raise ValueError("The profile [{}] is not plugged".format(profile))
-        self.profiles.unplug(profile)
-
-    def clear_profile(self):
-        self.profiles.clear()
-
-    def new_widget(self, widget):
-        raise NotImplementedError
-
-    # bridge signals hanlers
-
-    def connected_handler(self, jid_s, profile):
-        """Called when the connection is made.
-
-        @param jid_s (unicode): the JID that we were assigned by the server,
-            as the resource might differ from the JID we asked for.
-        """
-        log.debug(_("Connected"))
-        self.profiles[profile].whoami = jid.JID(jid_s)
-        self.set_presence_status(profile=profile)
-        # FIXME: fill() is already called for all profiles when doing self.sync = True
-        #        a per-profile fill() should be done once, see below note
-        self.contact_lists[profile].fill()
-        # if we were already displaying widgets, they must be resynchronized
-        # FIXME: self.sync is for all profiles
-        #        while (dis)connection is per-profile.
-        #        A mechanism similar to sync should be available
-        #        on a per-profile basis
-        self.sync = True
-        self.profile_connected(profile)
-
-    def disconnected_handler(self, profile):
-        """called when the connection is closed"""
-        log.debug(_("Disconnected"))
-        self.contact_lists[profile].disconnect()
-        # FIXME: see note on connected_handler
-        self.sync = False
-        self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)
-
-    def action_new_handler(self, action_data_s, id_, security_limit, profile):
-        self.action_manager(
-            data_format.deserialise(action_data_s), user_action=False, profile=profile
-        )
-
-    def contact_new_handler(self, jid_s, attributes, groups, profile):
-        entity = jid.JID(jid_s)
-        groups = list(groups)
-        self.contact_lists[profile].set_contact(entity, groups, attributes, in_roster=True)
-
-    def message_new_handler(
-            self, uid, timestamp, from_jid_s, to_jid_s, msg, subject, type_, extra_s,
-            profile):
-        from_jid = jid.JID(from_jid_s)
-        to_jid = jid.JID(to_jid_s)
-        extra = data_format.deserialise(extra_s)
-        if not self.trigger.point(
-            "messageNewTrigger", uid, timestamp, from_jid, to_jid, msg, subject, type_,
-            extra, profile=profile,):
-            return
-
-        from_me = from_jid.bare == self.profiles[profile].whoami.bare
-        mess_to_jid = to_jid if from_me else from_jid
-        target = mess_to_jid.bare
-        contact_list = self.contact_lists[profile]
-
-        try:
-            is_room = contact_list.is_room(target)
-        except exceptions.NotFound:
-            is_room = False
-
-        if target.resource and not is_room:
-            # we avoid resource locking, but we must keep resource for private MUC
-            # messages
-            target = target
-        # we want to be sure to have at least one QuickChat instance
-        self.widgets.get_or_create_widget(
-            quick_chat.QuickChat,
-            target,
-            type_ = C.CHAT_GROUP if is_room else C.CHAT_ONE2ONE,
-            on_new_widget = None,
-            profile = profile,
-        )
-
-        if (
-            not from_jid in contact_list
-            and from_jid.bare != self.profiles[profile].whoami.bare
-        ):
-            # XXX: needed to show entities which haven't sent any
-            #     presence information and which are not in roster
-            contact_list.set_contact(from_jid)
-
-        # we dispatch the message in the widgets
-        for widget in self.widgets.get_widgets(
-            quick_chat.QuickChat, target=target, profiles=(profile,)
-        ):
-            widget.message_new(
-                uid, timestamp, from_jid, mess_to_jid, msg, subject, type_, extra, profile
-            )
-
-    def message_encryption_started_handler(self, destinee_jid_s, plugin_data, profile):
-        destinee_jid = jid.JID(destinee_jid_s)
-        plugin_data = data_format.deserialise(plugin_data)
-        for widget in self.widgets.get_widgets(quick_chat.QuickChat,
-                                              target=destinee_jid.bare,
-                                              profiles=(profile,)):
-            widget.message_encryption_started(plugin_data)
-
-    def message_encryption_stopped_handler(self, destinee_jid_s, plugin_data, profile):
-        destinee_jid = jid.JID(destinee_jid_s)
-        for widget in self.widgets.get_widgets(quick_chat.QuickChat,
-                                              target=destinee_jid.bare,
-                                              profiles=(profile,)):
-            widget.message_encryption_stopped(plugin_data)
-
-    def message_state_handler(self, uid, status, profile):
-        for widget in self.widgets.get_widgets(quick_chat.QuickChat, profiles=(profile,)):
-            widget.on_message_state(uid, status, profile)
-
-    def message_send(self, to_jid, message, subject=None, mess_type="auto", extra=None, callback=None, errback=None, profile_key=C.PROF_KEY_NONE):
-        if not subject and not extra and (not message or message == {'': ''}):
-            log.debug("Not sending empty message")
-            return
-
-        if subject is None:
-            subject = {}
-        if extra is None:
-            extra = {}
-        if callback is None:
-            callback = (
-                lambda __=None: None
-            )  # FIXME: optional argument is here because pyjamas doesn't support callback
-               #        without arg with json proxy
-        if errback is None:
-            errback = lambda failure: self.show_dialog(
-                message=failure.message, title=failure.fullname, type="error"
-            )
-
-        if not self.trigger.point("messageSendTrigger", to_jid, message, subject, mess_type, extra, callback, errback, profile_key=profile_key):
-            return
-
-        self.bridge.message_send(
-            str(to_jid),
-            message,
-            subject,
-            mess_type,
-            data_format.serialise(extra),
-            profile_key,
-            callback=callback,
-            errback=errback,
-        )
-
-    def set_presence_status(self, show="", status=None, profile=C.PROF_KEY_NONE):
-        raise NotImplementedError
-
-    def presence_update_handler(self, entity_s, show, priority, statuses, profile):
-        # XXX: this log is commented because it's really too verbose even for DEBUG logs
-        #      but it is kept here as it may still be useful for troubleshooting
-        # log.debug(
-        #     _(
-        #         u"presence update for %(entity)s (show=%(show)s, priority=%(priority)s, "
-        #         u"statuses=%(statuses)s) [profile:%(profile)s]"
-        #     )
-        #     % {
-        #         "entity": entity_s,
-        #         C.PRESENCE_SHOW: show,
-        #         C.PRESENCE_PRIORITY: priority,
-        #         C.PRESENCE_STATUSES: statuses,
-        #         "profile": profile,
-        #     }
-        # )
-        entity = jid.JID(entity_s)
-
-        if entity == self.profiles[profile].whoami:
-            if show == C.PRESENCE_UNAVAILABLE:
-                self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)
-            else:
-                # FIXME: try to retrieve user language status before fallback to default
-                status = statuses.get(C.PRESENCE_STATUSES_DEFAULT, None)
-                self.set_presence_status(show, status, profile=profile)
-            return
-
-        self.call_listeners("presence", entity, show, priority, statuses, profile=profile)
-
-    def muc_room_joined_handler(
-            self, room_jid_s, occupants, user_nick, subject, statuses, profile):
-        """Called when a MUC room is joined"""
-        log.debug(
-            "Room [{room_jid}] joined by {profile}, users presents:{users}".format(
-                room_jid=room_jid_s, profile=profile, users=list(occupants.keys())
-            )
-        )
-        room_jid = jid.JID(room_jid_s)
-        self.contact_lists[profile].set_special(room_jid, C.CONTACT_SPECIAL_GROUP)
-        self.widgets.get_or_create_widget(
-            quick_chat.QuickChat,
-            room_jid,
-            type_=C.CHAT_GROUP,
-            nick=user_nick,
-            occupants=occupants,
-            subject=subject,
-            statuses=statuses,
-            profile=profile,
-        )
-
-    def muc_room_left_handler(self, room_jid_s, profile):
-        """Called when a MUC room is left"""
-        log.debug(
-            "Room [%(room_jid)s] left by %(profile)s"
-            % {"room_jid": room_jid_s, "profile": profile}
-        )
-        room_jid = jid.JID(room_jid_s)
-        chat_widget = self.widgets.get_widget(quick_chat.QuickChat, room_jid, profile)
-        if chat_widget:
-            self.widgets.delete_widget(
-                chat_widget, all_instances=True, explicit_close=True)
-        self.contact_lists[profile].remove_contact(room_jid)
-
-    def muc_room_user_changed_nick_handler(self, room_jid_s, old_nick, new_nick, profile):
-        """Called when an user joined a MUC room"""
-        room_jid = jid.JID(room_jid_s)
-        chat_widget = self.widgets.get_or_create_widget(
-            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
-        )
-        chat_widget.change_user_nick(old_nick, new_nick)
-        log.debug(
-            "user [%(old_nick)s] is now known as [%(new_nick)s] in room [%(room_jid)s]"
-            % {"old_nick": old_nick, "new_nick": new_nick, "room_jid": room_jid}
-        )
-
-    def muc_room_new_subject_handler(self, room_jid_s, subject, profile):
-        """Called when subject of MUC room change"""
-        room_jid = jid.JID(room_jid_s)
-        chat_widget = self.widgets.get_or_create_widget(
-            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
-        )
-        chat_widget.set_subject(subject)
-        log.debug(
-            "new subject for room [%(room_jid)s]: %(subject)s"
-            % {"room_jid": room_jid, "subject": subject}
-        )
-
-    def chat_state_received_handler(self, from_jid_s, state, profile):
-        """Called when a new chat state (XEP-0085) is received.
-
-        @param from_jid_s (unicode): JID of a contact or C.ENTITY_ALL
-        @param state (unicode): new state
-        @param profile (unicode): current profile
-        """
-        from_jid = jid.JID(from_jid_s)
-        for widget in self.widgets.get_widgets(quick_chat.QuickChat, target=from_jid.bare,
-                                              profiles=(profile,)):
-            widget.on_chat_state(from_jid, state, profile)
-
-    def notify(self, type_, entity=None, message=None, subject=None, callback=None,
-               cb_args=None, widget=None, profile=C.PROF_KEY_NONE):
-        """Trigger an event notification
-
-        @param type_(unicode): notifation kind,
-            one of C.NOTIFY_* constant or any custom type specific to frontend
-        @param entity(jid.JID, None): entity involved in the notification
-            if entity is in contact list, a indicator may be added in front of it
-        @param message(unicode, None): message of the notification
-        @param subject(unicode, None): subject of the notification
-        @param callback(callable, None): method to call when notification is selected
-        @param cb_args(list, None): list of args for callback
-        @param widget(object, None): widget where the notification happened
-        """
-        assert type_ in C.NOTIFY_ALL
-        notif_dict = self.profiles[profile].notifications
-        key = "" if entity is None else entity.bare
-        type_notifs = notif_dict.setdefault(key, {}).setdefault(type_, [])
-        notif_data = {
-            "id": self._notif_id,
-            "time": time.time(),
-            "entity": entity,
-            "callback": callback,
-            "cb_args": cb_args,
-            "message": message,
-            "subject": subject,
-        }
-        if widget is not None:
-            notif_data[widget] = widget
-        type_notifs.append(notif_data)
-        self._notifications[self._notif_id] = notif_data
-        self._notif_id += 1
-        self.call_listeners("notification", entity, notif_data, profile=profile)
-
-    def get_notifs(self, entity=None, type_=None, exact_jid=None, profile=C.PROF_KEY_NONE):
-        """return notifications for given entity
-
-        @param entity(jid.JID, None, C.ENTITY_ALL): jid of the entity to check
-            bare jid to get all notifications, full jid to filter on resource
-            None to get general notifications
-            C.ENTITY_ALL to get all notifications
-        @param type_(unicode, None): notification type to filter
-            None to get all notifications
-        @param exact_jid(bool, None): if True, only return notifications from
-            exact entity jid (i.e. not including other resources)
-            None for automatic selection (True for full jid, False else)
-            False to get resources notifications
-            False doesn't do anything if entity is not a bare jid
-        @return (iter[dict]): notifications
-        """
-        main_notif_dict = self.profiles[profile].notifications
-
-        if entity is C.ENTITY_ALL:
-            selected_notifs = iter(main_notif_dict.values())
-            exact_jid = False
-        else:
-            if entity is None:
-                key = ""
-                exact_jid = False
-            else:
-                key = entity.bare
-                if exact_jid is None:
-                    exact_jid = bool(entity.resource)
-            selected_notifs = (main_notif_dict.setdefault(key, {}),)
-
-        for notifs_from_select in selected_notifs:
-
-            if type_ is None:
-                type_notifs = iter(notifs_from_select.values())
-            else:
-                type_notifs = (notifs_from_select.get(type_, []),)
-
-            for notifs in type_notifs:
-                for notif in notifs:
-                    if exact_jid and notif["entity"] != entity:
-                        continue
-                    yield notif
-
-    def clear_notifs(self, entity, type_=None, profile=C.PROF_KEY_NONE):
-        """return notifications for given entity
-
-        @param entity(jid.JID, None): bare jid of the entity to check
-            None to clear general notifications (but keep entities ones)
-        @param type_(unicode, None): notification type to filter
-            None to clear all notifications
-        @return (list[dict]): list of notifications
-        """
-        notif_dict = self.profiles[profile].notifications
-        key = "" if entity is None else entity.bare
-        try:
-            if type_ is None:
-                del notif_dict[key]
-            else:
-                del notif_dict[key][type_]
-        except KeyError:
-            return
-        self.call_listeners("notificationsClear", entity, type_, profile=profile)
-
-    def ps_event_handler(self, category, service_s, node, event_type, data, profile):
-        """Called when a PubSub event is received.
-
-        @param category(unicode): event category (e.g. "PEP", "MICROBLOG")
-        @param service_s (unicode): pubsub service
-        @param node (unicode): pubsub node
-        @param event_type (unicode): event type (one of C.PUBLISH, C.RETRACT, C.DELETE)
-        @param data (serialised_dict): event data
-        """
-        data = data_format.deserialise(data)
-        service_s = jid.JID(service_s)
-
-        if category == C.PS_MICROBLOG and self.MB_HANDLER:
-            if event_type == C.PS_PUBLISH:
-                if not "content" in data:
-                    log.warning("No content found in microblog data")
-                    return
-
-                # FIXME: check if [] make sense (instead of None)
-                _groups = data.get("group")
-
-                for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
-                    wid.add_entry_if_accepted(service_s, node, data, _groups, profile)
-
-                try:
-                    comments_node, comments_service = (
-                        data["comments_node"],
-                        data["comments_service"],
-                    )
-                except KeyError:
-                    pass
-                else:
-                    self.bridge.mb_get(
-                        comments_service,
-                        comments_node,
-                        C.NO_LIMIT,
-                        [],
-                        {"subscribe": C.BOOL_TRUE},
-                        profile=profile,
-                    )
-            elif event_type == C.PS_RETRACT:
-                for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
-                    wid.delete_entry_if_present(service_s, node, data["id"], profile)
-                pass
-            else:
-                log.warning("Unmanaged PubSub event type {}".format(event_type))
-
-    def register_progress_cbs(self, progress_id, callback, errback):
-        """Register progression callbacks
-
-        @param progress_id(unicode): id of the progression to check
-        @param callback(callable, None): method to call when progressing action
-            successfuly finished.
-            None to ignore
-        @param errback(callable, None): method to call when progressions action failed
-            None to ignore
-        """
-        callbacks = self._progress_ids.setdefault(progress_id, [])
-        callbacks.append((callback, errback))
-
-    def progress_started_handler(self, pid, metadata, profile):
-        log.info("Progress {} started".format(pid))
-
-    def progress_finished_handler(self, pid, metadata, profile):
-        log.info("Progress {} finished".format(pid))
-        try:
-            callbacks = self._progress_ids.pop(pid)
-        except KeyError:
-            pass
-        else:
-            for callback, __ in callbacks:
-                if callback is not None:
-                    callback(metadata, profile=profile)
-        self.call_listeners("progress_finished", pid, metadata, profile=profile)
-
-    def progress_error_handler(self, pid, err_msg, profile):
-        log.warning("Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg))
-        try:
-            callbacks = self._progress_ids.pop(pid)
-        except KeyError:
-            pass
-        else:
-            for __, errback in callbacks:
-                if errback is not None:
-                    errback(err_msg, profile=profile)
-        self.call_listeners("progress_error", pid, err_msg, profile=profile)
-
-    def _subscribe_cb(self, answer, data):
-        entity, profile = data
-        type_ = "subscribed" if answer else "unsubscribed"
-        self.bridge.subscription(type_, str(entity.bare), profile_key=profile)
-
-    def subscribe_handler(self, type, raw_jid, profile):
-        """Called when a subsciption management signal is received"""
-        entity = jid.JID(raw_jid)
-        if type == "subscribed":
-            # this is a subscription confirmation, we just have to inform user
-            # TODO: call self.getEntityMBlog to add the new contact blogs
-            self.show_dialog(
-                _("The contact {contact} has accepted your subscription").format(
-                    contact=entity.bare
-                ),
-                _("Subscription confirmation"),
-            )
-        elif type == "unsubscribed":
-            # this is a subscription refusal, we just have to inform user
-            self.show_dialog(
-                _("The contact {contact} has refused your subscription").format(
-                    contact=entity.bare
-                ),
-                _("Subscription refusal"),
-                "error",
-            )
-        elif type == "subscribe":
-            # this is a subscriptionn request, we have to ask for user confirmation
-            # TODO: use sat.stdui.ui_contact_list to display the groups selector
-            self.show_dialog(
-                _(
-                    "The contact {contact} wants to subscribe to your presence"
-                    ".\nDo you accept ?"
-                ).format(contact=entity.bare),
-                _("Subscription confirmation"),
-                "yes/no",
-                answer_cb=self._subscribe_cb,
-                answer_data=(entity, profile),
-            )
-
-    def _debug_handler(self, action, parameters, profile):
-        if action == "widgets_dump":
-            from pprint import pformat
-            log.info("Widgets dump:\n{data}".format(data=pformat(self.widgets._widgets)))
-        else:
-            log.warning("Unknown debug action: {action}".format(action=action))
-
-
-    def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None):
-        """Show a dialog to user
-
-        Frontends must override this method
-        @param message(unicode): body of the dialog
-        @param title(unicode): title of the dialog
-        @param type(unicode): one of:
-            - "info": information dialog (callbacks not used)
-            - "warning": important information to notice (callbacks not used)
-            - "error": something went wrong (callbacks not used)
-            - "yes/no": a dialog with 2 choices (yes and no)
-        @param answer_cb(callable): method to call on answer.
-            Arguments depend on dialog type:
-            - "yes/no": argument is a boolean (True for yes)
-        @param answer_data(object): data to link on callback
-        """
-        # FIXME: misnamed method + types are not well chosen. Need to be rethought
-        raise NotImplementedError
-
-    def show_alert(self, message):
-        # FIXME: doesn't seems used anymore, to remove?
-        pass  # FIXME
-
-    def dialog_failure(self, failure):
-        log.warning("Failure: {}".format(failure))
-
-    def progress_id_handler(self, progress_id, profile):
-        """Callback used when an action result in a progress id"""
-        log.info("Progress ID received: {}".format(progress_id))
-
-    def is_hidden(self):
-        """Tells if the frontend window is hidden.
-
-        @return bool
-        """
-        raise NotImplementedError
-
-    def param_update_handler(self, name, value, namespace, profile):
-        log.debug(
-            _("param update: [%(namespace)s] %(name)s = %(value)s")
-            % {"namespace": namespace, "name": name, "value": value}
-        )
-        if (namespace, name) == ("Connection", "JabberID"):
-            log.debug(_("Changing JID to %s") % value)
-            self.profiles[profile].whoami = jid.JID(value)
-        elif (namespace, name) == ("General", C.SHOW_OFFLINE_CONTACTS):
-            self.contact_lists[profile].show_offline_contacts(C.bool(value))
-        elif (namespace, name) == ("General", C.SHOW_EMPTY_GROUPS):
-            self.contact_lists[profile].show_empty_groups(C.bool(value))
-
-    def contact_deleted_handler(self, jid_s, profile):
-        target = jid.JID(jid_s)
-        self.contact_lists[profile].remove_contact(target)
-
-    def entity_data_updated_handler(self, entity_s, key, value_raw, profile):
-        entity = jid.JID(entity_s)
-        value = data_format.deserialise(value_raw, type_check=None)
-        if key == "nicknames":
-            assert isinstance(value, list) or value is None
-            if entity in self.contact_lists[profile]:
-                self.contact_lists[profile].set_cache(entity, "nicknames", value)
-                self.call_listeners("nicknames", entity, value, profile=profile)
-        elif key == "avatar" and self.AVATARS_HANDLER:
-            assert isinstance(value, dict) or value is None
-            self.contact_lists[profile].set_cache(entity, "avatar", value)
-            self.call_listeners("avatar", entity, value, profile=profile)
-
-    def action_manager(self, action_data, callback=None, ui_show_cb=None, user_action=True,
-                      progress_cb=None, progress_eb=None, profile=C.PROF_KEY_NONE):
-        """Handle backend action
-
-        @param action_data(dict): action dict as sent by action_launch or returned by an
-            UI action
-        @param callback(None, callback): if not None, callback to use on XMLUI answer
-        @param ui_show_cb(None, callback): if not None, method to call to show the XMLUI
-        @param user_action(bool): if True, the action is a result of a user interaction
-            else the action come from backend direclty (i.e. action_new).
-            This is useful to know if the frontend can display a popup immediately (if
-            True) or if it should add it to a queue that the user can activate later.
-        @param progress_cb(None, callable): method to call when progression is finished.
-            Only make sense if a progress is expected in this action
-        @param progress_eb(None, callable): method to call when something went wrong
-            during progression.
-            Only make sense if a progress is expected in this action
-        """
-        try:
-            xmlui = action_data.pop("xmlui")
-        except KeyError:
-            pass
-        else:
-            ui = self.xmlui.create(
-                self,
-                xml_data=xmlui,
-                flags=("FROM_BACKEND",) if not user_action else None,
-                callback=callback,
-                profile=profile,
-            )
-            if ui_show_cb is None:
-                ui.show()
-            else:
-                ui_show_cb(ui)
-
-        try:
-            progress_id = action_data.pop("progress")
-        except KeyError:
-            pass
-        else:
-            if progress_cb or progress_eb:
-                self.register_progress_cbs(progress_id, progress_cb, progress_eb)
-            self.progress_id_handler(progress_id, profile)
-
-    def _action_cb(self, data, callback, callback_id, profile):
-        if callback is None:
-            self.action_manager(data, profile=profile)
-        else:
-            callback(data=data, cb_id=callback_id, profile=profile)
-
-    def action_launch(
-        self, callback_id, data=None, callback=None, profile=C.PROF_KEY_NONE
-    ):
-        """Launch a dynamic action
-
-        @param callback_id: id of the action to launch
-        @param data: data needed only for certain actions
-        @param callback(callable, None): will be called with the resut
-            if None, self.action_manager will be called
-            else the callable will be called with the following kw parameters:
-                - data: action_data
-                - cb_id: callback id
-                - profile: %(doc_profile)s
-        @param profile: %(doc_profile)s
-
-        """
-        if data is None:
-            data = dict()
-        action_cb = lambda data: self._action_cb(
-            data_format.deserialise(data), callback, callback_id, profile
-        )
-        self.bridge.action_launch(
-            callback_id, data_format.serialise(data), profile, callback=action_cb,
-            errback=self.dialog_failure
-        )
-
-    def launch_menu(
-        self,
-        menu_type,
-        path,
-        data=None,
-        callback=None,
-        security_limit=C.SECURITY_LIMIT_MAX,
-        profile=C.PROF_KEY_NONE,
-    ):
-        """Launch a menu manually
-
-        @param menu_type(unicode): type of the menu to launch
-        @param path(iterable[unicode]): path to the menu
-        @param data: data needed only for certain actions
-        @param callback(callable, None): will be called with the resut
-            if None, self.action_manager will be called
-            else the callable will be called with the following kw parameters:
-                - data: action_data
-                - cb_id: (menu_type, path) tuple
-                - profile: %(doc_profile)s
-        @param profile: %(doc_profile)s
-
-        """
-        if data is None:
-            data = dict()
-        action_cb = lambda data: self._action_cb(
-            data, callback, (menu_type, path), profile
-        )
-        self.bridge.menu_launch(
-            menu_type,
-            path,
-            data,
-            security_limit,
-            profile,
-            callback=action_cb,
-            errback=self.dialog_failure,
-        )
-
-    def disconnect(self, profile):
-        log.info("disconnecting")
-        self.call_listeners("disconnect", profile=profile)
-        self.bridge.disconnect(profile)
-
-    def on_exit(self):
-        """Must be called when the frontend is terminating"""
-        to_unplug = []
-        for profile, profile_manager in self.profiles.items():
-            if profile_manager.connected and profile_manager.autodisconnect:
-                # The user wants autodisconnection
-                self.disconnect(profile)
-            to_unplug.append(profile)
-        for profile in to_unplug:
-            self.unplug_profile(profile)
--- a/sat_frontends/quick_frontend/quick_blog.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,530 +0,0 @@
-#!/usr/bin/env python3
-
-
-# helper class for making a SAT frontend
-# Copyright (C) 2011-2021 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.i18n import _, D_
-from libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-from sat_frontends.quick_frontend.constants import Const as C
-from sat_frontends.quick_frontend import quick_widgets
-from sat_frontends.tools import jid
-from libervia.backend.tools.common import data_format
-
-try:
-    # FIXME: to be removed when an acceptable solution is here
-    str("")  # XXX: unicode doesn't exist in pyjamas
-except (
-    TypeError,
-    AttributeError,
-):  # Error raised is not the same depending on pyjsbuild options
-    str = str
-
-ENTRY_CLS = None
-COMMENTS_CLS = None
-
-
-class Item(object):
-    """Manage all (meta)data of an item"""
-
-    def __init__(self, data):
-        """
-        @param data(dict): microblog data as return by bridge methods
-            if data values are not defined, set default values
-        """
-        self.id = data["id"]
-        self.title = data.get("title")
-        self.title_rich = None
-        self.title_xhtml = data.get("title_xhtml")
-        self.tags = data.get('tags', [])
-        self.content = data.get("content")
-        self.content_rich = None
-        self.content_xhtml = data.get("content_xhtml")
-        self.author = data["author"]
-        try:
-            author_jid = data["author_jid"]
-            self.author_jid = jid.JID(author_jid) if author_jid else None
-        except KeyError:
-            self.author_jid = None
-
-        self.author_verified = data.get("author_jid_verified", False)
-
-        try:
-            self.updated = float(
-                data["updated"]
-            )  # XXX: int doesn't work here (pyjamas bug)
-        except KeyError:
-            self.updated = None
-
-        try:
-            self.published = float(
-                data["published"]
-            )  # XXX: int doesn't work here (pyjamas bug)
-        except KeyError:
-            self.published = None
-
-        self.comments = data.get("comments")
-        try:
-            self.comments_service = jid.JID(data["comments_service"])
-        except KeyError:
-            self.comments_service = None
-        self.comments_node = data.get("comments_node")
-
-    # def loadComments(self):
-    #     """Load all the comments"""
-    #     index = str(main_entry.comments_count - main_entry.hidden_count)
-    #     rsm = {'max': str(main_entry.hidden_count), 'index': index}
-    #     self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm)
-
-
-class EntriesManager(object):
-    """Class which manages list of (micro)blog entries"""
-
-    def __init__(self, manager):
-        """
-        @param manager (EntriesManager, None): parent EntriesManager
-            must be None for QuickBlog (and only for QuickBlog)
-        """
-        self.manager = manager
-        if manager is None:
-            self.blog = self
-        else:
-            self.blog = manager.blog
-        self.entries = []
-        self.edit_entry = None
-
-    @property
-    def level(self):
-        """indicate how deep is this entry in the tree
-
-        if level == -1, we have a QuickBlog
-        if level == 0, we have a main item
-        else we have a comment
-        """
-        level = -1
-        manager = self.manager
-        while manager is not None:
-            level += 1
-            manager = manager.manager
-        return level
-
-    def _add_mb_items(self, items_tuple, service=None, node=None):
-        """Add Microblog items to this panel
-        update is NOT called after addition
-
-        @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mb_get
-        """
-        items, metadata = items_tuple
-        for item in items:
-            self.add_entry(item, service=service, node=node, with_update=False)
-
-    def _add_mb_items_with_comments(self, items_tuple, service=None, node=None):
-        """Add Microblog items to this panel
-        update is NOT called after addition
-
-        @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mb_get
-        """
-        items, metadata = items_tuple
-        for item, comments in items:
-            self.add_entry(item, comments, service=service, node=node, with_update=False)
-
-    def add_entry(self, item=None, comments=None, service=None, node=None,
-                 with_update=True, editable=False, edit_entry=False):
-        """Add a microblog entry
-
-        @param editable (bool): True if the entry can be modified
-        @param item (dict, None): blog item data, or None for an empty entry
-        @param comments (list, None): list of comments data if available
-        @param service (jid.JID, None): service where the entry is coming from
-        @param service (unicode, None): node hosting the entry
-        @param with_update (bool): if True, udpate is called with the new entry
-        @param edit_entry(bool): if True, will be in self.edit_entry instead of
-            self.entries, so it can be managed separately (e.g. first or last
-            entry regardless of sorting)
-        """
-        new_entry = ENTRY_CLS(self, item, comments, service=service, node=node)
-        new_entry.set_editable(editable)
-        if edit_entry:
-            self.edit_entry = new_entry
-        else:
-            self.entries.append(new_entry)
-        if with_update:
-            self.update()
-        return new_entry
-
-    def update(self, entry=None):
-        """Update the display with entries
-
-        @param entry (Entry, None): if not None, must be the new entry.
-            If None, all the items will be checked to update the display
-        """
-        # update is separated from add_entry to allow adding
-        # several entries at once, and updating at the end
-        raise NotImplementedError
-
-
-class Entry(EntriesManager):
-    """Graphical representation of an Item
-    This class must be overriden by frontends"""
-
-    def __init__(
-        self, manager, item_data=None, comments_data=None, service=None, node=None
-    ):
-        """
-        @param blog(QuickBlog): the parent QuickBlog
-        @param manager(EntriesManager): the parent EntriesManager
-        @param item_data(dict, None): dict containing the blog item data, or None for an empty entry
-        @param comments_data(list, None): list of comments data
-        """
-        assert manager is not None
-        EntriesManager.__init__(self, manager)
-        self.service = service
-        self.node = node
-        self.editable = False
-        self.reset(item_data)
-        self.blog.id2entries[self.item.id] = self
-        if self.item.comments:
-            node_tuple = (self.item.comments_service, self.item.comments_node)
-            self.blog.node2entries.setdefault(node_tuple, []).append(self)
-
-    def reset(self, item_data):
-        """Reset the entry with given data
-
-        used during init (it's a set and not a reset then)
-        or later (e.g. message sent, or cancellation of an edition
-        @param idem_data(dict, None): data as in __init__
-        """
-        if item_data is None:
-            self.new = True
-            item_data = {
-                "id": None,
-                # TODO: find a best author value
-                "author": self.blog.host.whoami.node,
-            }
-        else:
-            self.new = False
-        self.item = Item(item_data)
-        self.author_jid = self.blog.host.whoami.bare if self.new else self.item.author_jid
-        if self.author_jid is None and self.service and self.service.node:
-            self.author_jid = self.service
-        self.mode = (
-            C.ENTRY_MODE_TEXT if self.item.content_xhtml is None else C.ENTRY_MODE_XHTML
-        )
-
-    def refresh(self):
-        """Refresh the display when data have been modified"""
-        pass
-
-    def set_editable(self, editable=True):
-        """tell if the entry can be edited or not
-
-        @param editable(bool): True if the entry can be edited
-        """
-        # XXX: we don't use @property as property setter doesn't play well with pyjamas
-        raise NotImplementedError
-
-    def add_comments(self, comments_data):
-        """Add comments to this entry by calling add_entry repeatidly
-
-        @param comments_data(tuple): data as returned by mb_get_from_many*RTResults
-        """
-        # TODO: manage seperator between comments of coming from different services/nodes
-        for data in comments_data:
-            service, node, failure, comments, metadata = data
-            for comment in comments:
-                if not failure:
-                    self.add_entry(comment, service=jid.JID(service), node=node)
-                else:
-                    log.warning("getting comment failed: {}".format(failure))
-        self.update()
-
-    def send(self):
-        """Send entry according to parent QuickBlog configuration and current level"""
-
-        # keys to keep other than content*, title* and tag*
-        # FIXME: see how to avoid comments node hijacking (someone could bind his post to another post's comments node)
-        keys_to_keep = ("id", "comments", "author", "author_jid", "published")
-
-        mb_data = {}
-        for key in keys_to_keep:
-            value = getattr(self.item, key)
-            if value is not None:
-                mb_data[key] = str(value)
-
-        for prefix in ("content", "title"):
-            for suffix in ("", "_rich", "_xhtml"):
-                name = "{}{}".format(prefix, suffix)
-                value = getattr(self.item, name)
-                if value is not None:
-                    mb_data[name] = value
-
-        mb_data['tags'] = self.item.tags
-
-        if self.blog.new_message_target not in (C.PUBLIC, C.GROUP):
-            raise NotImplementedError
-
-        if self.level == 0:
-            mb_data["allow_comments"] = True
-
-        if self.blog.new_message_target == C.GROUP:
-            mb_data['groups'] = list(self.blog.targets)
-
-        self.blog.host.bridge.mb_send(
-            str(self.service or ""),
-            self.node or "",
-            data_format.serialise(mb_data),
-            profile=self.blog.profile,
-        )
-
-    def delete(self):
-        """Remove this Entry from parent manager
-
-        This doesn't delete any entry in PubSub, just locally
-        all children entries will be recursively removed too
-        """
-        # XXX: named delete and not remove to avoid conflict with pyjamas
-        log.debug("deleting entry {}".format("EDIT ENTRY" if self.new else self.item.id))
-        for child in self.entries:
-            child.delete()
-        try:
-            self.manager.entries.remove(self)
-        except ValueError:
-            if self != self.manager.edit_entry:
-                log.error("Internal Error: entry not found in manager")
-            else:
-                self.manager.edit_entry = None
-        if not self.new:
-            # we must remove references to self
-            # in QuickBlog's dictionary
-            del self.blog.id2entries[self.item.id]
-            if self.item.comments:
-                comments_tuple = (self.item.comments_service, self.item.comments_node)
-                other_entries = self.blog.node2entries[comments_tuple].remove(self)
-                if not other_entries:
-                    del self.blog.node2entries[comments_tuple]
-
-    def retract(self):
-        """Retract this item from microblog node
-
-        if there is a comments node, it will be purged too
-        """
-        # TODO: manage several comments nodes case.
-        if self.item.comments:
-            self.blog.host.bridge.ps_node_delete(
-                str(self.item.comments_service) or "",
-                self.item.comments_node,
-                profile=self.blog.profile,
-            )
-        self.blog.host.bridge.mb_retract(
-            str(self.service or ""),
-            self.node or "",
-            self.item.id,
-            profile=self.blog.profile,
-        )
-
-
-class QuickBlog(EntriesManager, quick_widgets.QuickWidget):
-    def __init__(self, host, targets, profiles=None):
-        """Panel used to show microblog
-
-        @param targets (tuple(unicode)): contact groups displayed in this panel.
-            If empty, show all microblogs from all contacts. targets is also used
-            to know where to send new messages.
-        """
-        EntriesManager.__init__(self, None)
-        self.id2entries = {}  # used to find an entry with it's item id
-        # must be kept up-to-date by Entry
-        self.node2entries = {}  # same as above, values are lists in case of
-        # two entries link to the same comments node
-        if not targets:
-            targets = ()  # XXX: we use empty tuple instead of None to workaround a pyjamas bug
-            quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE)
-            self._targets_type = C.ALL
-        else:
-            assert isinstance(targets[0], str)
-            quick_widgets.QuickWidget.__init__(self, host, targets[0], C.PROF_KEY_NONE)
-            for target in targets[1:]:
-                assert isinstance(target, str)
-                self.add_target(target)
-            self._targets_type = C.GROUP
-
-    @property
-    def new_message_target(self):
-        if self._targets_type == C.ALL:
-            return C.PUBLIC
-        elif self._targets_type == C.GROUP:
-            return C.GROUP
-        else:
-            raise ValueError("Unkown targets type")
-
-    def __str__(self):
-        return "Blog Widget [target: {}, profile: {}]".format(
-            ", ".join(self.targets), self.profile
-        )
-
-    def _get_results_cb(self, data, rt_session):
-        remaining, results = data
-        log.debug(
-            "Got {got_len} results, {rem_len} remaining".format(
-                got_len=len(results), rem_len=remaining
-            )
-        )
-        for result in results:
-            service, node, failure, items_data, metadata = result
-            for item_data in items_data:
-                item_data[0] = data_format.deserialise(item_data[0])
-                for item_metadata in item_data[1]:
-                    item_metadata[3] = [data_format.deserialise(i) for i in item_metadata[3]]
-            if not failure:
-                self._add_mb_items_with_comments((items_data, metadata),
-                                             service=jid.JID(service))
-
-        self.update()
-        if remaining:
-            self._get_results(rt_session)
-
-    def _get_results_eb(self, failure):
-        log.warning("microblog get_from_many error: {}".format(failure))
-
-    def _get_results(self, rt_session):
-        """Manage results from mb_get_from_many RT Session
-
-        @param rt_session(str): session id as returned by mb_get_from_many
-        """
-        self.host.bridge.mb_get_from_many_with_comments_rt_result(
-            rt_session,
-            profile=self.profile,
-            callback=lambda data: self._get_results_cb(data, rt_session),
-            errback=self._get_results_eb,
-        )
-
-    def get_all(self):
-        """Get all (micro)blogs from self.targets"""
-
-        def got_session(rt_session):
-            self._get_results(rt_session)
-
-        if self._targets_type in (C.ALL, C.GROUP):
-            targets = tuple(self.targets) if self._targets_type is C.GROUP else ()
-            self.host.bridge.mb_get_from_many_with_comments(
-                self._targets_type,
-                targets,
-                10,
-                10,
-                {},
-                {"subscribe": C.BOOL_TRUE},
-                profile=self.profile,
-                callback=got_session,
-            )
-            own_pep = self.host.whoami.bare
-            self.host.bridge.mb_get_from_many_with_comments(
-                C.JID,
-                (str(own_pep),),
-                10,
-                10,
-                {},
-                {},
-                profile=self.profile,
-                callback=got_session,
-            )
-        else:
-            raise NotImplementedError(
-                "{} target type is not managed".format(self._targets_type)
-            )
-
-    def is_jid_accepted(self, jid_):
-        """Tell if a jid is actepted and must be shown in this panel
-
-        @param jid_(jid.JID): jid to check
-        @return: True if the jid is accepted
-        """
-        if self._targets_type == C.ALL:
-            return True
-        assert self._targets_type is C.GROUP  # we don't manage other types for now
-        for group in self.targets:
-            if self.host.contact_lists[self.profile].is_entity_in_group(jid_, group):
-                return True
-        return False
-
-    def add_entry_if_accepted(self, service, node, mb_data, groups, profile):
-        """add entry to this panel if it's acceptable
-
-        This method check if the entry is new or an update,
-        if it below to a know node, or if it acceptable anyway
-        @param service(jid.JID): jid of the emitting pubsub service
-        @param node(unicode): node identifier
-        @param mb_data: microblog data
-        @param groups(list[unicode], None): groups which can receive this entry
-            None to accept everything
-        @param profile: %(doc_profile)s
-        """
-        try:
-            entry = self.id2entries[mb_data["id"]]
-        except KeyError:
-            # The entry is new
-            try:
-                parent_entries = self.node2entries[(service, node)]
-            except:
-                # The node is unknown,
-                # we need to check that we can accept the entry
-                if (
-                    self.is_jid_accepted(service)
-                    or (
-                        groups is None
-                        and service == self.host.profiles[self.profile].whoami.bare
-                    )
-                    or (groups and groups.intersection(self.targets))
-                ):
-                    self.add_entry(mb_data, service=service, node=node)
-            else:
-                # the entry is a comment in a known node
-                for parent_entry in parent_entries:
-                    parent_entry.add_entry(mb_data, service=service, node=node)
-        else:
-            # The entry exist, it's an update
-            entry.reset(mb_data)
-            entry.refresh()
-
-    def delete_entry_if_present(self, service, node, item_id, profile):
-        """Delete and entry if present in this QuickBlog
-
-        @param sender(jid.JID): jid of the entry sender
-        @param mb_data: microblog data
-        @param service(jid.JID): sending service
-        @param node(unicode): hosting node
-        """
-        try:
-            entry = self.id2entries[item_id]
-        except KeyError:
-            pass
-        else:
-            entry.delete()
-
-
-def register_class(type_, cls):
-    global ENTRY_CLS, COMMENTS_CLS
-    if type_ == "ENTRY":
-        ENTRY_CLS = cls
-    elif type == "COMMENT":
-        COMMENTS_CLS = cls
-    else:
-        raise ValueError("type_ should be ENTRY or COMMENT")
-    if COMMENTS_CLS is None:
-        COMMENTS_CLS = ENTRY_CLS
--- a/sat_frontends/quick_frontend/quick_chat.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,941 +0,0 @@
-#!/usr/bin/env python3
-
-# helper class for making a SàT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-from libervia.backend.core.log import getLogger
-from libervia.backend.tools.common import data_format
-from libervia.backend.core import exceptions
-from sat_frontends.quick_frontend import quick_widgets
-from sat_frontends.quick_frontend.constants import Const as C
-from collections import OrderedDict
-from sat_frontends.tools import jid
-import time
-
-
-log = getLogger(__name__)
-
-
-ROOM_USER_JOINED = "ROOM_USER_JOINED"
-ROOM_USER_LEFT = "ROOM_USER_LEFT"
-ROOM_USER_MOVED = (ROOM_USER_JOINED, ROOM_USER_LEFT)
-
-# from datetime import datetime
-
-# FIXME: day_format need to be settable (i18n)
-
-
-class Message:
-    """Message metadata"""
-
-    def __init__(
-            self, parent, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra,
-            profile):
-        self.parent = parent
-        self.profile = profile
-        self.uid = uid
-        self.timestamp = timestamp
-        self.from_jid = from_jid
-        self.to_jid = to_jid
-        self.message = msg
-        self.subject = subject
-        self.type = type_
-        self.extra = extra
-        self.nick = self.get_nick(from_jid)
-        self._status = None
-        # own_mess is True if message was sent by profile's jid
-        self.own_mess = (
-            (from_jid.resource == self.parent.nick)
-            if self.parent.type == C.CHAT_GROUP
-            else (from_jid.bare == self.host.profiles[profile].whoami.bare)
-        )
-        # is user mentioned here ?
-        if self.parent.type == C.CHAT_GROUP and not self.own_mess:
-            for m in msg.values():
-                if self.parent.nick.lower() in m.lower():
-                    self._mention = True
-                    break
-        self.handle_me()
-        self.widgets = set()  # widgets linked to this message
-
-    def __str__(self):
-        return "Message<{mess_type}>  [{time}]{nick}> {message}".format(
-            mess_type=self.type,
-            time=self.time_text,
-            nick=self.nick,
-            message=self.main_message)
-
-    def __contains__(self, item):
-        return hasattr(self, item) or item in self.extra
-
-    @property
-    def host(self):
-        return self.parent.host
-
-    @property
-    def info_type(self):
-        return self.extra.get("info_type")
-
-    @property
-    def mention(self):
-        try:
-            return self._mention
-        except AttributeError:
-            return False
-
-    @property
-    def history(self):
-        """True if message come from history"""
-        return self.extra.get("history", False)
-
-    @property
-    def main_message(self):
-        """currently displayed message"""
-        if self.parent.lang in self.message:
-            self.selected_lang = self.parent.lang
-            return self.message[self.parent.lang]
-        try:
-            self.selected_lang = ""
-            return self.message[""]
-        except KeyError:
-            try:
-                lang, mess = next(iter(self.message.items()))
-                self.selected_lang = lang
-                return mess
-            except StopIteration:
-                if not self.attachments:
-                    # we may have empty messages if we have attachments
-                    log.error("Can't find message for uid {}".format(self.uid))
-                return ""
-
-    @property
-    def main_message_xhtml(self):
-        """rich message"""
-        xhtml = {k: v for k, v in self.extra.items() if "html" in k}
-        if xhtml:
-            # FIXME: we only return first found value for now
-            return next(iter(xhtml.values()))
-
-    @property
-    def time_text(self):
-        """Return timestamp in a nicely formatted way"""
-        # if the message was sent before today, we print the full date
-        timestamp = time.localtime(self.timestamp)
-        time_format = "%c" if timestamp < self.parent.day_change else "%H:%M"
-        return time.strftime(time_format, timestamp)
-
-    @property
-    def avatar(self):
-        """avatar data or None if no avatar is found"""
-        entity = self.from_jid
-        contact_list = self.host.contact_lists[self.profile]
-        try:
-            return contact_list.getCache(entity, "avatar")
-        except (exceptions.NotFound, KeyError):
-            # we don't check the result as the avatar listener will be called
-            self.host.bridge.avatar_get(entity, True, self.profile)
-            return None
-
-    @property
-    def encrypted(self):
-        return self.extra.get("encrypted", False)
-
-    def get_nick(self, entity):
-        """Return nick of an entity when possible"""
-        contact_list = self.host.contact_lists[self.profile]
-        if self.type == C.MESS_TYPE_INFO and self.info_type in ROOM_USER_MOVED:
-            try:
-                return self.extra["user_nick"]
-            except KeyError:
-                log.error("extra data is missing user nick for uid {}".format(self.uid))
-                return ""
-        # FIXME: converted get_specials to list for pyjamas
-        if self.parent.type == C.CHAT_GROUP or entity in list(
-            contact_list.get_specials(C.CONTACT_SPECIAL_GROUP)
-        ):
-            return entity.resource or ""
-        if entity.bare in contact_list:
-
-            try:
-                nicknames = contact_list.getCache(entity, "nicknames")
-            except (exceptions.NotFound, KeyError):
-                # we check result as listener will be called
-                self.host.bridge.identity_get(
-                    entity.bare, ["nicknames"], True, self.profile)
-                return entity.node or entity
-
-            if nicknames:
-                return nicknames[0]
-            else:
-                return (
-                    contact_list.getCache(entity, "name", default=None)
-                    or entity.node
-                    or entity
-                )
-
-        return entity.node or entity
-
-    @property
-    def status(self):
-        return self._status
-
-    @status.setter
-    def status(self, status):
-        if status != self._status:
-            self._status = status
-            for w in self.widgets:
-                w.update({"status": status})
-
-    def handle_me(self):
-        """Check if messages starts with "/me " and change them if it is the case
-
-        if several messages (different languages) are presents, they all need to start with "/me "
-        """
-        # TODO: XHTML-IM /me are not handled
-        me = False
-        # we need to check /me for every message
-        for m in self.message.values():
-            if m.startswith("/me "):
-                me = True
-            else:
-                me = False
-                break
-        if me:
-            self.type = C.MESS_TYPE_INFO
-            self.extra["info_type"] = "me"
-            nick = self.nick
-            for lang, mess in self.message.items():
-                self.message[lang] = "* " + nick + mess[3:]
-
-    @property
-    def attachments(self):
-        return self.extra.get(C.KEY_ATTACHMENTS)
-
-
-class MessageWidget:
-    """Base classe for widgets"""
-    # This class does nothing and is only used to have a common ancestor
-
-    pass
-
-
-class Occupant:
-    """Occupant metadata"""
-
-    def __init__(self, parent, data, profile):
-        self.parent = parent
-        self.profile = profile
-        self.nick = data["nick"]
-        self._entity = data.get("entity")
-        self.affiliation = data["affiliation"]
-        self.role = data["role"]
-        self.widgets = set()  # widgets linked to this occupant
-        self._state = None
-
-    @property
-    def data(self):
-        """reconstruct data dict from attributes"""
-        data = {}
-        data["nick"] = self.nick
-        if self._entity is not None:
-            data["entity"] = self._entity
-        data["affiliation"] = self.affiliation
-        data["role"] = self.role
-        return data
-
-    @property
-    def jid(self):
-        """jid in the room"""
-        return jid.JID("{}/{}".format(self.parent.target.bare, self.nick))
-
-    @property
-    def real_jid(self):
-        """real jid if known else None"""
-        return self._entity
-
-    @property
-    def host(self):
-        return self.parent.host
-
-    @property
-    def state(self):
-        return self._state
-
-    @state.setter
-    def state(self, new_state):
-        if new_state != self._state:
-            self._state = new_state
-            for w in self.widgets:
-                w.update({"state": new_state})
-
-    def update(self, update_dict=None):
-        for w in self.widgets:
-            w.update(update_dict)
-
-
-class QuickChat(quick_widgets.QuickWidget):
-    visible_states = ["chat_state"]  # FIXME: to be removed, used only in quick_games
-
-    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None,
-                 subject=None, statuses=None, profiles=None):
-        """
-        @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for
-                      chat à la IRC
-        """
-        self.lang = ""  # default language to use for messages
-        quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles)
-        assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP)
-        self.current_target = target
-        self.type = type_
-        self.encrypted = False  # True if this session is currently encrypted
-        self._locked = False
-        # True when resync is in progress, avoid resynchronising twice when resync is called
-        # and history is still being updated. For internal use only
-        self._resync_lock = False
-        self.set_locked()
-        if type_ == C.CHAT_GROUP:
-            if target.resource:
-                raise exceptions.InternalError(
-                    "a group chat entity can't have a resource"
-                )
-            if nick is None:
-                raise exceptions.InternalError("nick must not be None for group chat")
-
-            self.nick = nick
-            self.occupants = {}
-            self.set_occupants(occupants)
-        else:
-            if occupants is not None or nick is not None:
-                raise exceptions.InternalError(
-                    "only group chat can have occupants or nick"
-                )
-        self.messages = OrderedDict()  # key: uid, value: Message instance
-        self.games = {}  # key=game name (unicode), value=instance of quick_games.RoomGame
-        self.subject = subject
-        self.statuses = set(statuses or [])
-        lt = time.localtime()
-        self.day_change = (
-            lt.tm_year,
-            lt.tm_mon,
-            lt.tm_mday,
-            0,
-            0,
-            0,
-            lt.tm_wday,
-            lt.tm_yday,
-            lt.tm_isdst,
-        )  # struct_time of day changing time
-        if self.host.AVATARS_HANDLER:
-            self.host.addListener("avatar", self.on_avatar, profiles)
-
-    def set_locked(self):
-        """Set locked flag
-
-        To be set when we are waiting for history/search
-        """
-        # FIXME: we don't use getter/setter here because of pyjamas
-        # TODO: use proper getter/setter once we get rid of pyjamas
-        if self._locked:
-            log.warning("{wid} is already locked!".format(wid=self))
-            return
-        self._locked = True
-        # message_new signals are cached when locked
-        self._cache = OrderedDict()
-        log.debug("{wid} is now locked".format(wid=self))
-
-    def set_unlocked(self):
-        if not self._locked:
-            log.debug("{wid} was already unlocked".format(wid=self))
-            return
-        self._locked = False
-        for uid, data in self._cache.items():
-            if uid not in self.messages:
-                self.message_new(*data)
-            else:
-                log.debug("discarding message already in history: {data}, ".format(data=data))
-        del self._cache
-        log.debug("{wid} is now unlocked".format(wid=self))
-
-    def post_init(self):
-        """Method to be called by frontend after widget is initialised
-
-        handle the display of history and subject
-        """
-        self.history_print(profile=self.profile)
-        if self.subject is not None:
-            self.set_subject(self.subject)
-        if self.host.ENCRYPTION_HANDLERS:
-            self.get_encryption_state()
-
-    def on_delete(self):
-        if self.host.AVATARS_HANDLER:
-            self.host.removeListener("avatar", self.on_avatar)
-
-    @property
-    def contact_list(self):
-        return self.host.contact_lists[self.profile]
-
-    @property
-    def message_widgets_rev(self):
-        """Return the history of MessageWidget in reverse chronological order
-
-        Must be implemented by frontend
-        """
-        raise NotImplementedError
-
-    ## synchornisation handling ##
-
-    @quick_widgets.QuickWidget.sync.setter
-    def sync(self, state):
-        quick_widgets.QuickWidget.sync.fset(self, state)
-        if not state:
-            self.set_locked()
-
-    def _resync_complete(self):
-        self.sync = True
-        self._resync_lock = False
-
-    def occupants_clear(self):
-        """Remove all occupants
-
-        Must be overridden by frontends to clear their own representations of occupants
-        """
-        self.occupants.clear()
-
-    def resync(self):
-        if self._resync_lock:
-            return
-        self._resync_lock = True
-        log.debug("resynchronising {self}".format(self=self))
-        for mess in reversed(list(self.messages.values())):
-            if mess.type == C.MESS_TYPE_INFO:
-                continue
-            last_message = mess
-            break
-        else:
-            # we have no message yet, we can get normal history
-            self.history_print(callback=self._resync_complete, profile=self.profile)
-            return
-        if self.type == C.CHAT_GROUP:
-            self.occupants_clear()
-            self.host.bridge.muc_occupants_get(
-                str(self.target), self.profile, callback=self.update_occupants,
-                errback=log.error)
-        self.history_print(
-            size=C.HISTORY_LIMIT_NONE,
-            filters={'timestamp_start': last_message.timestamp},
-            callback=self._resync_complete,
-            profile=self.profile)
-
-    ## Widget management ##
-
-    def __str__(self):
-        return "Chat Widget [target: {}, type: {}, profile: {}]".format(
-            self.target, self.type, self.profile
-        )
-
-    @staticmethod
-    def get_widget_hash(target, profiles):
-        profile = list(profiles)[0]
-        return profile + "\n" + str(target.bare)
-
-    @staticmethod
-    def get_private_hash(target, profile):
-        """Get unique hash for private conversations
-
-        This method should be used with force_hash to get unique widget for private MUC conversations
-        """
-        return (str(profile), target)
-
-    def add_target(self, target):
-        super(QuickChat, self).add_target(target)
-        if target.resource:
-            self.current_target = (
-                target
-            )  # FIXME: tmp, must use resource priority throught contactList instead
-
-    def recreate_args(self, args, kwargs):
-        """copy important attribute for a new widget"""
-        kwargs["type_"] = self.type
-        if self.type == C.CHAT_GROUP:
-            kwargs["occupants"] = {o.nick: o.data for o in self.occupants.values()}
-        kwargs["subject"] = self.subject
-        try:
-            kwargs["nick"] = self.nick
-        except AttributeError:
-            pass
-
-    def on_private_created(self, widget):
-        """Method called when a new widget for private conversation (MUC) is created"""
-        raise NotImplementedError
-
-    def get_or_create_private_widget(self, entity):
-        """Create a widget for private conversation, or get it if it already exists
-
-        @param entity: full jid of the target
-        """
-        return self.host.widgets.get_or_create_widget(
-            QuickChat,
-            entity,
-            type_=C.CHAT_ONE2ONE,
-            force_hash=self.get_private_hash(self.profile, entity),
-            on_new_widget=self.on_private_created,
-            profile=self.profile,
-        )  # we force hash to have a new widget, not this one again
-
-    @property
-    def target(self):
-        if self.type == C.CHAT_GROUP:
-            return self.current_target.bare
-        return self.current_target
-
-    ## occupants ##
-
-    def set_occupants(self, occupants):
-        """Set the whole list of occupants"""
-        assert len(self.occupants) == 0
-        for nick, data in occupants.items():
-            # XXX: this log is disabled because it's really too verbose
-            #      but kept commented as it may be useful for debugging
-            # log.debug(u"adding occupant {nick} to {room}".format(
-            #     nick=nick, room=self.target))
-            self.occupants[nick] = Occupant(self, data, self.profile)
-
-    def update_occupants(self, occupants):
-        """Update occupants list
-
-        In opposition to set_occupants, this only add missing occupants and remove
-        occupants who have left
-        """
-        # FIXME: occupants with modified status are not handled
-        local_occupants = set(self.occupants)
-        updated_occupants = set(occupants)
-        left_occupants = local_occupants - updated_occupants
-        joined_occupants = updated_occupants - local_occupants
-        log.debug("updating occupants for {room}:\n"
-                  "left: {left_occupants}\n"
-                  "joined: {joined_occupants}"
-                  .format(room=self.target,
-                          left_occupants=", ".join(left_occupants),
-                          joined_occupants=", ".join(joined_occupants)))
-        for nick in left_occupants:
-            self.removeUser(occupants[nick])
-        for nick in joined_occupants:
-            self.addUser(occupants[nick])
-
-    def addUser(self, occupant_data):
-        """Add user if it is not in the group list"""
-        occupant = Occupant(self, occupant_data, self.profile)
-        self.occupants[occupant.nick] = occupant
-        return occupant
-
-    def removeUser(self, occupant_data):
-        """Remove a user from the group list"""
-        nick = occupant_data["nick"]
-        try:
-            occupant = self.occupants.pop(nick)
-        except KeyError:
-            log.warning("Trying to remove an unknown occupant: {}".format(nick))
-        else:
-            return occupant
-
-    def set_user_nick(self, nick):
-        """Set the nick of the user, usefull for e.g. change the color of the user"""
-        self.nick = nick
-
-    def change_user_nick(self, old_nick, new_nick):
-        """Change nick of a user in group list"""
-        log.info("{old} is now known as {new} in room {room_jid}".format(
-            old = old_nick,
-            new = new_nick,
-            room_jid = self.target))
-
-    ## Messages ##
-
-    def manage_message(self, entity, mess_type):
-        """Tell if this chat widget manage an entity and message type couple
-
-        @param entity (jid.JID): (full) jid of the sending entity
-        @param mess_type (str): message type as given by message_new
-        @return (bool): True if this Chat Widget manage this couple
-        """
-        if self.type == C.CHAT_GROUP:
-            if (
-                mess_type in (C.MESS_TYPE_GROUPCHAT, C.MESS_TYPE_INFO)
-                and self.target == entity.bare
-            ):
-                return True
-        else:
-            if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets:
-                return True
-        return False
-
-    def update_history(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile="@NONE@"):
-        """Called when history need to be recreated
-
-        Remove all message from history then call history_print
-        Must probably be overriden by frontend to clear widget
-        @param size (int): number of messages
-        @param filters (str): patterns to filter the history results
-        @param profile (str): %(doc_profile)s
-        """
-        self.set_locked()
-        self.messages.clear()
-        self.history_print(size, filters, profile=profile)
-
-    def _on_history_printed(self):
-        """Method called when history is printed (or failed)
-
-        unlock the widget, and can be used to refresh or scroll down
-        the focus after the history is printed
-        """
-        self.set_unlocked()
-
-    def history_print(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, callback=None,
-                     profile="@NONE@"):
-        """Print the current history
-
-        Note: self.set_unlocked will be called once history is printed
-        @param size (int): number of messages
-        @param search (str): pattern to filter the history results
-        @param callback(callable, None): method to call when history has been printed
-        @param profile (str): %(doc_profile)s
-        """
-        if filters is None:
-            filters = {}
-        if size == 0:
-            log.debug("Empty history requested, skipping")
-            self._on_history_printed()
-            return
-        log_msg = _("now we print the history")
-        if size != C.HISTORY_LIMIT_DEFAULT:
-            log_msg += _(" ({} messages)".format(size))
-        log.debug(log_msg)
-
-        if self.type == C.CHAT_ONE2ONE:
-            special = self.host.contact_lists[self.profile].getCache(
-                self.target, C.CONTACT_SPECIAL, create_if_not_found=True, default=None
-            )
-            if special == C.CONTACT_SPECIAL_GROUP:
-                # we have a private conversation
-                # so we need full jid for the history
-                # (else we would get history from group itself)
-                # and to filter out groupchat message
-                target = self.target
-                filters["not_types"] = C.MESS_TYPE_GROUPCHAT
-            else:
-                target = self.target.bare
-        else:
-            # groupchat
-            target = self.target.bare
-            # FIXME: info not handled correctly
-            filters["types"] = C.MESS_TYPE_GROUPCHAT
-
-        self.history_filters = filters
-
-        def _history_get_cb(history):
-            # day_format = "%A, %d %b %Y"  # to display the day change
-            # previous_day = datetime.now().strftime(day_format)
-            # message_day = datetime.fromtimestamp(timestamp).strftime(self.day_format)
-            # if previous_day != message_day:
-            #     self.print_day_change(message_day)
-            #     previous_day = message_day
-            for data in history:
-                uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data
-                from_jid = jid.JID(from_jid)
-                to_jid = jid.JID(to_jid)
-                extra = data_format.deserialise(extra_s)
-                # if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or
-                #    (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)):
-                #     continue
-                extra["history"] = True
-                self.messages[uid] = Message(
-                    self,
-                    uid,
-                    timestamp,
-                    from_jid,
-                    to_jid,
-                    message,
-                    subject,
-                    type_,
-                    extra,
-                    profile,
-                )
-            self._on_history_printed()
-            if callback is not None:
-                callback()
-
-        def _history_get_eb(err):
-            log.error(_("Can't get history: {}").format(err))
-            self._on_history_printed()
-            if callback is not None:
-                callback()
-
-        self.host.bridge.history_get(
-            str(self.host.profiles[profile].whoami.bare),
-            str(target),
-            size,
-            True,
-            {k: str(v) for k,v in filters.items()},
-            profile,
-            callback=_history_get_cb,
-            errback=_history_get_eb,
-        )
-
-    def message_encryption_get_cb(self, session_data):
-        if session_data:
-            session_data = data_format.deserialise(session_data)
-            self.message_encryption_started(session_data)
-
-    def message_encryption_get_eb(self, failure_):
-        log.error(_("Can't get encryption state: {reason}").format(reason=failure_))
-
-    def get_encryption_state(self):
-        """Retrieve encryption state with current target.
-
-        Once state is retrieved, default message_encryption_started will be called if
-        suitable
-        """
-        if self.type == C.CHAT_GROUP:
-            return
-        self.host.bridge.message_encryption_get(str(self.target.bare), self.profile,
-                                              callback=self.message_encryption_get_cb,
-                                              errback=self.message_encryption_get_eb)
-
-
-    def message_new(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra,
-                   profile):
-        if self._locked:
-            self._cache[uid] = (
-                uid,
-                timestamp,
-                from_jid,
-                to_jid,
-                msg,
-                subject,
-                type_,
-                extra,
-                profile,
-            )
-            return
-
-        if ((not msg and not subject and not extra[C.KEY_ATTACHMENTS]
-             and type_ != C.MESS_TYPE_INFO)):
-            log.warning("Received an empty message for uid {}".format(uid))
-            return
-
-        if self.type == C.CHAT_GROUP:
-            if to_jid.resource and type_ != C.MESS_TYPE_GROUPCHAT:
-                # we have a private message, we forward it to a private conversation
-                # widget
-                chat_widget = self.get_or_create_private_widget(to_jid)
-                chat_widget.message_new(
-                    uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile
-                )
-                return
-            if type_ == C.MESS_TYPE_INFO:
-                try:
-                    info_type = extra["info_type"]
-                except KeyError:
-                    pass
-                else:
-                    user_data = {
-                        k[5:]: v for k, v in extra.items() if k.startswith("user_")
-                    }
-                    if info_type == ROOM_USER_JOINED:
-                        self.addUser(user_data)
-                    elif info_type == ROOM_USER_LEFT:
-                        self.removeUser(user_data)
-
-        message = Message(
-            self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile
-        )
-        self.messages[uid] = message
-
-        if "received_timestamp" in extra:
-            log.warning("Delayed message received after history, this should not happen")
-        self.create_message(message)
-
-    def message_encryption_started(self, session_data):
-        self.encrypted = True
-        log.debug(_("message encryption started with {target} using {encryption}").format(
-            target=self.target, encryption=session_data['name']))
-
-    def message_encryption_stopped(self, session_data):
-        self.encrypted = False
-        log.debug(_("message encryption stopped with {target} (was using {encryption})")
-                 .format(target=self.target, encryption=session_data['name']))
-
-    def create_message(self, message, append=False):
-        """Must be implemented by frontend to create and show a new message widget
-
-        This is only called on message_new, not on history.
-        You need to override history_print to handle the later
-        @param message(Message): message data
-        """
-        raise NotImplementedError
-
-    def is_user_moved(self, message):
-        """Return True if message is a user left/joined message
-
-        @param message(Message): message to check
-        @return (bool): True is message is user moved info message
-        """
-        if message.type != C.MESS_TYPE_INFO:
-            return False
-        try:
-            info_type = message.extra["info_type"]
-        except KeyError:
-            return False
-        else:
-            return info_type in ROOM_USER_MOVED
-
-    def handle_user_moved(self, message):
-        """Check if this message is a UserMoved one, and merge it when possible
-
-        "merge it" means that info message indicating a user joined/left will be
-        grouped if no other non-info messages has been sent since
-        @param message(Message): message to check
-        @return (bool): True if this message has been merged
-            if True, a new MessageWidget must not be created and appended to history
-        """
-        if self.is_user_moved(message):
-            for wid in self.message_widgets_rev:
-                # we merge in/out messages if no message was sent meanwhile
-                if not isinstance(wid, MessageWidget):
-                    continue
-                elif wid.mess_data.type != C.MESS_TYPE_INFO:
-                    return False
-                elif (
-                    wid.info_type in ROOM_USER_MOVED
-                    and wid.mess_data.nick == message.nick
-                ):
-                    try:
-                        count = wid.reentered_count
-                    except AttributeError:
-                        count = wid.reentered_count = 1
-                    nick = wid.mess_data.nick
-                    if message.info_type == ROOM_USER_LEFT:
-                        wid.message = _("<= {nick} has left the room ({count})").format(
-                            nick=nick, count=count
-                        )
-                    else:
-                        wid.message = _(
-                            "<=> {nick} re-entered the room ({count})"
-                        ).format(nick=nick, count=count)
-                        wid.reentered_count += 1
-                    return True
-        return False
-
-    def print_day_change(self, day):
-        """Display the day on a new line.
-
-        @param day(unicode): day to display (or not if this method is not overwritten)
-        """
-        # FIXME: not called anymore after refactoring
-        pass
-
-    ## Room ##
-
-    def set_subject(self, subject):
-        """Set title for a group chat"""
-        if self.type != C.CHAT_GROUP:
-            raise exceptions.InternalError(
-                "trying to set subject for a non group chat window"
-            )
-        self.subject = subject
-
-    def change_subject(self, new_subject):
-        """Change the subject of the room
-
-        This change the subject on the room itself (i.e. via XMPP),
-        while set_subject change the subject of this widget
-        """
-        self.host.bridge.muc_subject(str(self.target), new_subject, self.profile)
-
-    def add_game_panel(self, widget):
-        """Insert a game panel to this Chat dialog.
-
-        @param widget (Widget): the game panel
-        """
-        raise NotImplementedError
-
-    def remove_game_panel(self, widget):
-        """Remove the game panel from this Chat dialog.
-
-        @param widget (Widget): the game panel
-        """
-        raise NotImplementedError
-
-    def update(self, entity=None):
-        """Update one or all entities.
-
-        @param entity (jid.JID): entity to update
-        """
-        # FIXME: to remove ?
-        raise NotImplementedError
-
-    ## events ##
-
-    def on_chat_state(self, from_jid, state, profile):
-        """A chat state has been received"""
-        if self.type == C.CHAT_GROUP:
-            nick = from_jid.resource
-            try:
-                self.occupants[nick].state = state
-            except KeyError:
-                log.warning(
-                    "{nick} not found in {room}, ignoring new chat state".format(
-                        nick=nick, room=self.target.bare
-                    )
-                )
-
-    def on_message_state(self, uid, status, profile):
-        try:
-            mess_data = self.messages[uid]
-        except KeyError:
-            pass
-        else:
-            mess_data.status = status
-
-    def on_avatar(self, entity, avatar_data, profile):
-        if self.type == C.CHAT_GROUP:
-            if entity.bare == self.target:
-                try:
-                    self.occupants[entity.resource].update({"avatar": avatar_data})
-                except KeyError:
-                    # can happen for a message in history where the
-                    # entity is not here anymore
-                    pass
-
-                for m in list(self.messages.values()):
-                    if m.nick == entity.resource:
-                        for w in m.widgets:
-                            w.update({"avatar": avatar_data})
-        else:
-            if (
-                entity.bare == self.target.bare
-                or entity.bare == self.host.profiles[profile].whoami.bare
-            ):
-                log.info("avatar updated for {}".format(entity))
-                for m in list(self.messages.values()):
-                    if m.from_jid.bare == entity.bare:
-                        for w in m.widgets:
-                            w.update({"avatar": avatar_data})
-
-
-quick_widgets.register(QuickChat)
--- a/sat_frontends/quick_frontend/quick_contact_list.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1113 +0,0 @@
-#!/usr/bin/env python3
-
-# helper class for making a SàT frontend contact lists
-# Copyright (C) 2009-2021 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/>.
-
-"""Contact List handling multi profiles at once,
-    should replace quick_contact_list module in the future"""
-
-from libervia.backend.core.i18n import _
-from libervia.backend.core.log import getLogger
-from libervia.backend.core import exceptions
-from sat_frontends.quick_frontend.quick_widgets import QuickWidget
-from sat_frontends.quick_frontend.constants import Const as C
-from sat_frontends.tools import jid
-from collections import OrderedDict
-
-log = getLogger(__name__)
-
-try:
-    # FIXME: to be removed when an acceptable solution is here
-    str("")  # XXX: unicode doesn't exist in pyjamas
-except (TypeError, AttributeError):  # Error raised is not the same depending on
-    # pyjsbuild options
-    # XXX: pyjamas' max doesn't support key argument, so we implement it ourself
-    pyjamas_max = max
-
-    def max(iterable, key):
-        iter_cpy = list(iterable)
-        iter_cpy.sort(key=key)
-        return pyjamas_max(iter_cpy)
-
-    # next doesn't exist in pyjamas
-    def next(iterable, *args):
-        try:
-            return iterable.__next__()
-        except StopIteration as e:
-            if args:
-                return args[0]
-            raise e
-
-
-handler = None
-
-
-class ProfileContactList(object):
-    """Contact list data for a single profile"""
-
-    def __init__(self, profile):
-        self.host = handler.host
-        self.profile = profile
-        # contain all jids in roster or not,
-        # bare jids as keys, resources are used in data
-        # XXX: we don't mutualise cache, as values may differ
-        # for different profiles (e.g. directed presence)
-        self._cache = {}
-
-        # special entities (groupchat, gateways, etc)
-        # may be bare or full jid
-        self._specials = set()
-
-        # group data contain jids in groups and misc frontend data
-        # None key is used for jids with no group
-        self._groups = {}  # groups to group data map
-
-        # contacts in roster (bare jids)
-        self._roster = set()
-
-        # selected entities, full jid
-        self._selected = set()
-
-        # options
-        self.show_disconnected = False
-        self._show_empty_groups = True
-        self.show_resources = False
-        self.show_status = False
-        # do we show entities with notifications?
-        # if True, entities will be show even if they normally would not
-        # (e.g. not in contact list) if they have notifications attached
-        self.show_entities_with_notifs = True
-
-        self.host.bridge.param_get_a_async(
-            C.SHOW_EMPTY_GROUPS,
-            "General",
-            profile_key=profile,
-            callback=self._show_empty_groups_cb,
-        )
-
-        self.host.bridge.param_get_a_async(
-            C.SHOW_OFFLINE_CONTACTS,
-            "General",
-            profile_key=profile,
-            callback=self._show_offline_contacts,
-        )
-
-        self.host.addListener("presence", self.on_presence_update, [self.profile])
-        self.host.addListener("nicknames", self.on_nicknames_update, [self.profile])
-        self.host.addListener("notification", self.on_notification, [self.profile])
-        # on_notification only updates the entity, so we can re-use it
-        self.host.addListener("notificationsClear", self.on_notification, [self.profile])
-
-    @property
-    def whoami(self):
-        return self.host.profiles[self.profile].whoami
-
-    def _show_empty_groups_cb(self, show_str):
-        # Called only by __init__
-        # self.update is not wanted here, as it is done by
-        # handler when all profiles are ready
-        self.show_empty_groups(C.bool(show_str))
-
-    def _show_offline_contacts(self, show_str):
-        # same comments as for _show_empty_groups
-        self.show_offline_contacts(C.bool(show_str))
-
-    def __contains__(self, entity):
-        """Check if entity is in contact list
-
-        An entity can be in contact list even if not in roster
-        use is_in_roster to check if entity is in roster.
-        @param entity (jid.JID): jid of the entity (resource is not ignored,
-            use bare jid if needed)
-        """
-        if entity.resource:
-            try:
-                return entity.resource in self.getCache(entity.bare, C.CONTACT_RESOURCES)
-            except exceptions.NotFound:
-                return False
-        return entity in self._cache
-
-    @property
-    def roster(self):
-        """Return all the bare JIDs of the roster entities.
-
-        @return (set[jid.JID])
-        """
-        return self._roster
-
-    @property
-    def roster_connected(self):
-        """Return all the bare JIDs of the roster entities that are connected.
-
-        @return (set[jid.JID])
-        """
-        return set(
-            [
-                entity
-                for entity in self._roster
-                if self.getCache(entity, C.PRESENCE_SHOW, default=None) is not None
-            ]
-        )
-
-    @property
-    def roster_entities_by_group(self):
-        """Return a dictionary binding the roster groups to their entities bare JIDs.
-
-        This also includes the empty group (None key).
-        @return (dict[unicode,set(jid.JID)])
-        """
-        return {group: self._groups[group]["jids"] for group in self._groups}
-
-    @property
-    def roster_groups_by_entities(self):
-        """Return a dictionary binding the entities bare JIDs to their roster groups
-
-        @return (dict[jid.JID, set(unicode)])
-        """
-        result = {}
-        for group, data in self._groups.items():
-            for entity in data["jids"]:
-                result.setdefault(entity, set()).add(group)
-        return result
-
-    @property
-    def selected(self):
-        """Return contacts currently selected
-
-        @return (set): set of selected entities
-        """
-        return self._selected
-
-    @property
-    def all_iter(self):
-        """return all know entities in cache as an iterator of tuples
-
-        entities are not sorted
-        """
-        return iter(self._cache.items())
-
-    @property
-    def items(self):
-        """Return item representation for all visible entities in cache
-
-        entities are not sorted
-        key: bare jid, value: data
-        """
-        return {
-            jid_: cache
-            for jid_, cache in self._cache.items()
-            if self.entity_visible(jid_)
-        }
-
-    def get_item(self, entity):
-        """Return item representation of requested entity
-
-        @param entity(jid.JID): bare jid of entity
-        @raise (KeyError): entity is unknown
-        """
-        return self._cache[entity]
-
-    def _got_contacts(self, contacts):
-        """Add contacts and notice parent that contacts are filled
-
-        Called during initial contact list filling
-        @param contacts(tuple): all contacts
-        """
-        for contact in contacts:
-            entity = jid.JID(contact[0])
-            if entity.resource:
-                # we use entity's bare jid to cache data, so a resource here
-                # will cause troubles
-                log.warning(
-                    "Roster entities with resources are not managed, ignoring {entity}"
-                    .format(entity=entity))
-                continue
-            self.host.contact_new_handler(*contact, profile=self.profile)
-        handler._contacts_filled(self.profile)
-
-    def _fill(self):
-        """Get all contacts from backend
-
-        Contacts will be cleared before refilling them
-        """
-        self.clear_contacts(keep_cache=True)
-        self.host.bridge.contacts_get(self.profile, callback=self._got_contacts)
-
-    def fill(self):
-        handler.fill(self.profile)
-
-    def getCache(
-        self, entity, name=None, bare_default=True, create_if_not_found=False,
-        default=Exception):
-        """Return a cache value for a contact
-
-        @param entity(jid.JID): entity of the contact from who we want data
-            (resource is used if given)
-            if a resource specific information is requested:
-                - if no resource is given (bare jid), the main resource is used,
-                    according to priority
-                - if resource is given, it is used
-        @param name(unicode): name the data to get, or None to get everything
-        @param bare_default(bool, None): if True and entity is a full jid,
-            the value of bare jid will be returned if not value is found for
-            the requested resource.
-            If False, None is returned if no value is found for the requested resource.
-            If None, bare_default will be set to False if entity is in a room, True else
-        @param create_if_not_found(bool): if True, create contact if it's not found
-            in cache
-        @param default(object): value to return when name is not found in cache
-            if Exception is used, a KeyError will be returned
-            otherwise, the given value will be used
-        @return: full cache if no name is given, or value of "name", or None
-        @raise NotFound: entity not found in cache
-        @raise KeyError: name not found in cache
-        """
-        # FIXME: resource handling need to be reworked
-        # FIXME: bare_default work for requesting full jid to get bare jid,
-        #        but not the other way
-        #        e.g.: if we have set an avatar for user@server.tld/resource
-        #        and we request user@server.tld
-        #        we won't get the avatar set in the resource
-        try:
-            cache = self._cache[entity.bare]
-        except KeyError:
-            if create_if_not_found:
-                self.set_contact(entity)
-                cache = self._cache[entity.bare]
-            else:
-                raise exceptions.NotFound
-
-        if name is None:
-            if default is not Exception:
-                raise exceptions.InternalError(
-                    "default value can only Exception when name is not specified"
-                )
-            # full cache is requested
-            return cache
-
-        if name in ("status", C.PRESENCE_STATUSES, C.PRESENCE_PRIORITY, C.PRESENCE_SHOW):
-            # these data are related to the resource
-            if not entity.resource:
-                main_resource = cache[C.CONTACT_MAIN_RESOURCE]
-                if main_resource is None:
-                    # we ignore presence info if we don't have any resource in cache
-                    # FIXME: to be checked
-                    return
-                cache = cache[C.CONTACT_RESOURCES].setdefault(main_resource, {})
-            else:
-                cache = cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {})
-
-            if name == "status":  # XXX: we get the first status for 'status' key
-                # TODO: manage main language for statuses
-                return cache[C.PRESENCE_STATUSES].get(C.PRESENCE_STATUSES_DEFAULT, "")
-
-        elif entity.resource:
-            try:
-                return cache[C.CONTACT_RESOURCES][entity.resource][name]
-            except KeyError as e:
-                if bare_default is None:
-                    bare_default = not self.is_room(entity.bare)
-                if not bare_default:
-                    if default is Exception:
-                        raise e
-                    else:
-                        return default
-
-        try:
-            return cache[name]
-        except KeyError as e:
-            if default is Exception:
-                raise e
-            else:
-                return default
-
-    def set_cache(self, entity, name, value):
-        """Set or update value for one data in cache
-
-        @param entity(JID): entity to update
-        @param name(str): value to set or update
-        """
-        self.set_contact(entity, attributes={name: value})
-
-    def get_full_jid(self, entity):
-        """Get full jid from a bare jid
-
-        @param entity(jid.JID): must be a bare jid
-        @return (jid.JID): bare jid + main resource
-        @raise ValueError: the entity is not bare
-        """
-        if entity.resource:
-            raise ValueError("get_full_jid must be used with a bare jid")
-        main_resource = self.getCache(entity, C.CONTACT_MAIN_RESOURCE)
-        return jid.JID("{}/{}".format(entity, main_resource))
-
-    def set_group_data(self, group, name, value):
-        """Register a data for a group
-
-        @param group: a valid (existing) group name
-        @param name: name of the data (can't be "jids")
-        @param value: value to set
-        """
-        assert name != "jids"
-        self._groups[group][name] = value
-
-    def get_group_data(self, group, name=None):
-        """Return value associated to group data
-
-        @param group: a valid (existing) group name
-        @param name: name of the data or None to get the whole dict
-        @return: registered value
-        """
-        if name is None:
-            return self._groups[group]
-        return self._groups[group][name]
-
-    def is_in_roster(self, entity):
-        """Tell if an entity is in roster
-
-        @param entity(jid.JID): jid of the entity
-            the bare jid will be used
-        """
-        return entity.bare in self._roster
-
-    def is_room(self, entity):
-        """Helper method to know if entity is a MUC room
-
-        @param entity(jid.JID): jid of the entity
-            hint: use bare jid here, as room can't be full jid with MUC
-        @return (bool): True if entity is a room
-        """
-        assert entity.resource is None  # FIXME: this may change when MIX will be handled
-        return self.is_special(entity, C.CONTACT_SPECIAL_GROUP)
-
-    def is_special(self, entity, special_type):
-        """Tell if an entity is of a specialy _type
-
-        @param entity(jid.JID): jid of the special entity
-            if the jid is full, will be added to special extras
-        @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP)
-        @return (bool): True if entity is from this special type
-        """
-        return self.getCache(entity, C.CONTACT_SPECIAL, default=None) == special_type
-
-    def set_special(self, entity, special_type):
-        """Set special flag on an entity
-
-        @param entity(jid.JID): jid of the special entity
-            if the jid is full, will be added to special extras
-        @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP)
-            or None to remove special flag
-        """
-        assert special_type in C.CONTACT_SPECIAL_ALLOWED + (None,)
-        self.set_cache(entity, C.CONTACT_SPECIAL, special_type)
-
-    def get_specials(self, special_type=None, bare=False):
-        """Return all the bare JIDs of the special roster entities of with given type.
-
-        @param special_type(unicode, None): if not None, filter by special type
-            (e.g. C.CONTACT_SPECIAL_GROUP)
-        @param bare(bool): return only bare jids if True
-        @return (iter[jid.JID]): found special entities
-        """
-        for entity in self._specials:
-            if bare and entity.resource:
-                continue
-            if (
-                special_type is not None
-                and self.getCache(entity, C.CONTACT_SPECIAL, default=None) != special_type
-            ):
-                continue
-            yield entity
-
-    def disconnect(self):
-        # for now we just clear contacts on disconnect
-        self.clear_contacts()
-
-    def clear_contacts(self, keep_cache=False):
-        """Clear all the contact list
-
-        @param keep_cache: if True, don't reset the cache
-        """
-        self.select(None)
-        if not keep_cache:
-            self._cache.clear()
-        self._groups.clear()
-        self._specials.clear()
-        self._roster.clear()
-        self.update()
-
-    def set_contact(self, entity, groups=None, attributes=None, in_roster=False):
-        """Add a contact to the list if it doesn't exist, else update it.
-
-        This method can be called with groups=None for the purpose of updating
-        the contact's attributes (e.g. nicknames). In that case, the groups
-        attribute must not be set to the default group but ignored. If not,
-        you may move your contact from its actual group(s) to the default one.
-
-        None value for 'groups' has a different meaning than [None]
-        which is for the default group.
-
-        @param entity (jid.JID): entity to add or replace
-            if entity is a full jid, attributes will be cached in for the full jid only
-        @param groups (list): list of groups or None to ignore the groups membership.
-        @param attributes (dict): attibutes of the added jid or to update
-        @param in_roster (bool): True if contact is from roster
-        """
-        if attributes is None:
-            attributes = {}
-
-        entity_bare = entity.bare
-        # we check if the entity is visible before changing anything
-        # this way we know if we need to do an UPDATE_ADD, UPDATE_MODIFY
-        # or an UPDATE_DELETE
-        was_visible = self.entity_visible(entity_bare)
-
-        if in_roster:
-            self._roster.add(entity_bare)
-
-        cache = self._cache.setdefault(
-            entity_bare,
-            {
-                C.CONTACT_RESOURCES: {},
-                C.CONTACT_MAIN_RESOURCE: None,
-                C.CONTACT_SELECTED: set(),
-            },
-        )
-
-        # we don't want forbidden data in attributes
-        assert not C.CONTACT_DATA_FORBIDDEN.intersection(attributes)
-
-        # we set groups and fill self._groups accordingly
-        if groups is not None:
-            if not groups:
-                groups = [None]  # [None] is the default group
-            if C.CONTACT_GROUPS in cache:
-                # XXX: don't use set(cache[C.CONTACT_GROUPS]).difference(groups) because
-                #      it won't work in Pyjamas if None is in cache[C.CONTACT_GROUPS]
-                for group in [
-                    group for group in cache[C.CONTACT_GROUPS] if group not in groups
-                ]:
-                    self._groups[group]["jids"].remove(entity_bare)
-            cache[C.CONTACT_GROUPS] = groups
-            for group in groups:
-                self._groups.setdefault(group, {}).setdefault("jids", set()).add(
-                    entity_bare
-                )
-
-        # special entities management
-        if C.CONTACT_SPECIAL in attributes:
-            if attributes[C.CONTACT_SPECIAL] is None:
-                del attributes[C.CONTACT_SPECIAL]
-                self._specials.remove(entity)
-            else:
-                self._specials.add(entity)
-                cache[C.CONTACT_MAIN_RESOURCE] = None
-                if 'nicknames' in cache:
-                    del cache['nicknames']
-
-        # now the attributes we keep in cache
-        # XXX: if entity is a full jid, we store the value for the resource only
-        cache_attr = (
-            cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {})
-            if entity.resource
-            else cache
-        )
-        for attribute, value in attributes.items():
-            if attribute == "nicknames" and self.is_special(
-                entity, C.CONTACT_SPECIAL_GROUP
-            ):
-                # we don't want to keep nicknames for MUC rooms
-                # FIXME: this is here as plugin XEP-0054 can link resource's nick
-                #        with bare jid which in the case of MUC
-                #        set the nick for the whole MUC
-                #        resulting in bad name displayed in some frontends
-                # FIXME: with plugin XEP-0054 + plugin identity refactoring, this
-                #        may not be needed anymore…
-                continue
-            cache_attr[attribute] = value
-
-        # we can update the display if needed
-        if self.entity_visible(entity_bare):
-            # if the contact was not visible, we need to add a widget
-            # else we just update id
-            update_type = C.UPDATE_MODIFY if was_visible else C.UPDATE_ADD
-            self.update([entity], update_type, self.profile)
-        elif was_visible:
-            # the entity was visible and is not anymore, we remove it
-            self.update([entity], C.UPDATE_DELETE, self.profile)
-
-    def entity_visible(self, entity, check_resource=False):
-        """Tell if the contact should be showed or hidden.
-
-        @param entity (jid.JID): jid of the contact
-        @param check_resource (bool): True if resource must be significant
-        @return (bool): True if that contact should be showed in the list
-        """
-        try:
-            show = self.getCache(entity, C.PRESENCE_SHOW)
-        except (exceptions.NotFound, KeyError):
-            return False
-
-        if check_resource:
-            selected = self._selected
-        else:
-            selected = {selected.bare for selected in self._selected}
-        return (
-            (show is not None and show != C.PRESENCE_UNAVAILABLE)
-            or self.show_disconnected
-            or entity in selected
-            or (
-                self.show_entities_with_notifs
-                and next(self.host.get_notifs(entity.bare, profile=self.profile), None)
-            )
-            or entity.resource is None and self.is_room(entity.bare)
-        )
-
-    def any_entity_visible(self, entities, check_resources=False):
-        """Tell if in a list of entities, at least one should be shown
-
-        @param entities (list[jid.JID]): list of jids
-        @param check_resources (bool): True if resources must be significant
-        @return (bool): True if a least one entity need to be shown
-        """
-        # FIXME: looks inefficient, really needed?
-        for entity in entities:
-            if self.entity_visible(entity, check_resources):
-                return True
-        return False
-
-    def is_entity_in_group(self, entity, group):
-        """Tell if an entity is in a roster group
-
-        @param entity(jid.JID): jid of the entity
-        @param group(unicode): group to check
-        @return (bool): True if the entity is in the group
-        """
-        return entity in self.get_group_data(group, "jids")
-
-    def remove_contact(self, entity):
-        """remove a contact from the list
-
-        @param entity(jid.JID): jid of the entity to remove (bare jid is used)
-        """
-        entity_bare = entity.bare
-        was_visible = self.entity_visible(entity_bare)
-        try:
-            groups = self._cache[entity_bare].get(C.CONTACT_GROUPS, set())
-        except KeyError:
-            log.error(_("Trying to delete an unknow entity [{}]").format(entity))
-        try:
-            self._roster.remove(entity_bare)
-        except KeyError:
-            pass
-        del self._cache[entity_bare]
-        for group in groups:
-            self._groups[group]["jids"].remove(entity_bare)
-            if not self._groups[group]["jids"]:
-                # FIXME: we use pop because of pyjamas:
-                #        http://wiki.goffi.org/wiki/Issues_with_Pyjamas/en
-                self._groups.pop(group)
-        for iterable in (self._selected, self._specials):
-            to_remove = set()
-            for set_entity in iterable:
-                if set_entity.bare == entity.bare:
-                    to_remove.add(set_entity)
-            iterable.difference_update(to_remove)
-        if was_visible:
-            self.update([entity], C.UPDATE_DELETE, self.profile)
-
-    def on_presence_update(self, entity, show, priority, statuses, profile):
-        """Update entity's presence status
-
-        @param entity(jid.JID): entity updated
-        @param show: availability
-        @parap priority: resource's priority
-        @param statuses: dict of statuses
-        @param profile: %(doc_profile)s
-        """
-        # FIXME: cache modification should be done with set_contact
-        #        the resources/presence handling logic should be moved there
-        was_visible = self.entity_visible(entity.bare)
-        cache = self.getCache(entity, create_if_not_found=True)
-        if show == C.PRESENCE_UNAVAILABLE:
-            if not entity.resource:
-                cache[C.CONTACT_RESOURCES].clear()
-                cache[C.CONTACT_MAIN_RESOURCE] = None
-            else:
-                try:
-                    del cache[C.CONTACT_RESOURCES][entity.resource]
-                except KeyError:
-                    log.error(
-                        "Presence unavailable received "
-                        "for an unknown resource [{}]".format(entity)
-                    )
-                if not cache[C.CONTACT_RESOURCES]:
-                    cache[C.CONTACT_MAIN_RESOURCE] = None
-        else:
-            if not entity.resource:
-                log.warning(
-                    _(
-                        "received presence from entity "
-                        "without resource: {}".format(entity)
-                    )
-                )
-            resources_data = cache[C.CONTACT_RESOURCES]
-            resource_data = resources_data.setdefault(entity.resource, {})
-            resource_data[C.PRESENCE_SHOW] = show
-            resource_data[C.PRESENCE_PRIORITY] = int(priority)
-            resource_data[C.PRESENCE_STATUSES] = statuses
-
-            if entity.bare not in self._specials:
-                # we may have resources with no priority
-                # (when a cached value is added for a not connected resource)
-                priority_resource = max(
-                    resources_data,
-                    key=lambda res: resources_data[res].get(
-                        C.PRESENCE_PRIORITY, -2 ** 32
-                    ),
-                )
-                cache[C.CONTACT_MAIN_RESOURCE] = priority_resource
-        if self.entity_visible(entity.bare):
-            update_type = C.UPDATE_MODIFY if was_visible else C.UPDATE_ADD
-            self.update([entity], update_type, self.profile)
-        elif was_visible:
-            self.update([entity], C.UPDATE_DELETE, self.profile)
-
-    def on_nicknames_update(self, entity, nicknames, profile):
-        """Update entity's nicknames
-
-        @param entity(jid.JID): entity updated
-        @param nicknames(list[unicode]): nicknames of the entity
-        @param profile: %(doc_profile)s
-        """
-        assert profile == self.profile
-        self.set_cache(entity, "nicknames", nicknames)
-
-    def on_notification(self, entity, notif, profile):
-        """Update entity with notification
-
-        @param entity(jid.JID): entity updated
-        @param notif(dict): notification data
-        @param profile: %(doc_profile)s
-        """
-        assert profile == self.profile
-        if entity is not None and self.entity_visible(entity):
-            self.update([entity], C.UPDATE_MODIFY, profile)
-
-    def unselect(self, entity):
-        """Unselect an entity
-
-         @param entity(jid.JID): entity to unselect
-        """
-        try:
-            cache = self._cache[entity.bare]
-        except:
-            log.error("Try to unselect an entity not in cache")
-        else:
-            try:
-                cache[C.CONTACT_SELECTED].remove(entity.resource)
-            except KeyError:
-                log.error("Try to unselect a not selected entity")
-            else:
-                self._selected.remove(entity)
-                self.update([entity], C.UPDATE_SELECTION)
-
-    def select(self, entity):
-        """Select an entity
-
-        @param entity(jid.JID, None): entity to select (resource is significant)
-            None to unselect all entities
-        """
-        if entity is None:
-            self._selected.clear()
-            for cache in self._cache.values():
-                cache[C.CONTACT_SELECTED].clear()
-            self.update(type_=C.UPDATE_SELECTION, profile=self.profile)
-        else:
-            log.debug("select %s" % entity)
-            try:
-                cache = self._cache[entity.bare]
-            except:
-                log.error("Try to select an entity not in cache")
-            else:
-                cache[C.CONTACT_SELECTED].add(entity.resource)
-                self._selected.add(entity)
-                self.update([entity], C.UPDATE_SELECTION, profile=self.profile)
-
-    def show_offline_contacts(self, show):
-        """Tell if offline contacts should be shown
-
-        @param show(bool): True if offline contacts should be shown
-        """
-        assert isinstance(show, bool)
-        if self.show_disconnected == show:
-            return
-        self.show_disconnected = show
-        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
-
-    def show_empty_groups(self, show):
-        assert isinstance(show, bool)
-        if self._show_empty_groups == show:
-            return
-        self._show_empty_groups = show
-        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
-
-    def show_resources(self, show):
-        assert isinstance(show, bool)
-        if self.show_resources == show:
-            return
-        self.show_resources = show
-        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
-
-    def plug(self):
-        handler.add_profile(self.profile)
-
-    def unplug(self):
-        handler.remove_profile(self.profile)
-
-    def update(self, entities=None, type_=None, profile=None):
-        handler.update(entities, type_, profile)
-
-
-class QuickContactListHandler(object):
-    def __init__(self, host):
-        super(QuickContactListHandler, self).__init__()
-        self.host = host
-        global handler
-        if handler is not None:
-            raise exceptions.InternalError(
-                "QuickContactListHandler must be instanciated only once"
-            )
-        handler = self
-        self._clist = {}  # key: profile, value: ProfileContactList
-        self._widgets = set()
-        self._update_locked = False  # se to True to ignore updates
-
-    def __getitem__(self, profile):
-        """Return ProfileContactList instance for the requested profile"""
-        return self._clist[profile]
-
-    def __contains__(self, entity):
-        """Check if entity is in contact list
-
-        @param entity (jid.JID): jid of the entity (resource is not ignored,
-            use bare jid if needed)
-        """
-        for contact_list in self._clist.values():
-            if entity in contact_list:
-                return True
-        return False
-
-    @property
-    def roster(self):
-        """Return all the bare JIDs of the roster entities.
-
-        @return (set[jid.JID])
-        """
-        entities = set()
-        for contact_list in self._clist.values():
-            entities.update(contact_list.roster)
-        return entities
-
-    @property
-    def roster_connected(self):
-        """Return all the bare JIDs of the roster entities that are connected.
-
-        @return (set[jid.JID])
-        """
-        entities = set()
-        for contact_list in self._clist.values():
-            entities.update(contact_list.roster_connected)
-        return entities
-
-    @property
-    def roster_entities_by_group(self):
-        """Return a dictionary binding the roster groups to their entities bare
-        JIDs. This also includes the empty group (None key).
-
-        @return (dict[unicode,set(jid.JID)])
-        """
-        groups = {}
-        for contact_list in self._clist.values():
-            groups.update(contact_list.roster_entities_by_group)
-        return groups
-
-    @property
-    def roster_groups_by_entities(self):
-        """Return a dictionary binding the entities bare JIDs to their roster
-        groups.
-
-        @return (dict[jid.JID, set(unicode)])
-        """
-        entities = {}
-        for contact_list in self._clist.values():
-            entities.update(contact_list.roster_groups_by_entities)
-        return entities
-
-    @property
-    def selected(self):
-        """Return contacts currently selected
-
-        @return (set): set of selected entities
-        """
-        entities = set()
-        for contact_list in self._clist.values():
-            entities.update(contact_list.selected)
-        return entities
-
-    @property
-    def all_iter(self):
-        """Return item representation for all entities in cache
-
-        items are unordered
-        """
-        for profile, contact_list in self._clist.items():
-            for bare_jid, cache in contact_list.all_iter:
-                data = cache.copy()
-                data[C.CONTACT_PROFILE] = profile
-                yield bare_jid, data
-
-    @property
-    def items(self):
-        """Return item representation for visible entities in cache
-
-        items are unordered
-        key: bare jid, value: data
-        """
-        items = {}
-        for profile, contact_list in self._clist.items():
-            for bare_jid, cache in contact_list.items.items():
-                data = cache.copy()
-                items[bare_jid] = data
-                data[C.CONTACT_PROFILE] = profile
-        return items
-
-    @property
-    def items_sorted(self):
-        """Return item representation for visible entities in cache
-
-        items are ordered using self.items_sort
-        key: bare jid, value: data
-        """
-        return self.items_sort(self.items)
-
-    def items_sort(self, items):
-        """sort items
-
-       @param items(dict): items to sort (will be emptied !)
-       @return (OrderedDict): sorted items
-       """
-        ordered_items = OrderedDict()
-        bare_jids = sorted(items.keys())
-        for jid_ in bare_jids:
-            ordered_items[jid_] = items.pop(jid_)
-        return ordered_items
-
-    def register(self, widget):
-        """Register a QuickContactList widget
-
-        This method should only be used in QuickContactList
-        """
-        self._widgets.add(widget)
-
-    def unregister(self, widget):
-        """Unregister a QuickContactList widget
-
-        This method should only be used in QuickContactList
-        """
-        self._widgets.remove(widget)
-
-    def add_profiles(self, profiles):
-        """Add a contact list for plugged profiles
-
-        @param profile(iterable[unicode]): plugged profiles
-        """
-        for profile in profiles:
-            if profile not in self._clist:
-                self._clist[profile] = ProfileContactList(profile)
-        return [self._clist[profile] for profile in profiles]
-
-    def add_profile(self, profile):
-        return self.add_profiles([profile])[0]
-
-    def remove_profiles(self, profiles):
-        """Remove given unplugged profiles from contact list
-
-        @param profile(iterable[unicode]): unplugged profiles
-        """
-        for profile in profiles:
-            del self._clist[profile]
-
-    def remove_profile(self, profile):
-        self.remove_profiles([profile])
-
-    def get_special_extras(self, special_type=None):
-        """Return special extras with given type
-
-        If special_type is None, return all special extras.
-
-        @param special_type(unicode, None): one of special type
-            (e.g. C.CONTACT_SPECIAL_GROUP)
-            None to return all special extras.
-        @return (set[jid.JID])
-        """
-        entities = set()
-        for contact_list in self._clist.values():
-            entities.update(contact_list.get_special_extras(special_type))
-        return entities
-
-    def _contacts_filled(self, profile):
-        self._to_fill.remove(profile)
-        if not self._to_fill:
-            del self._to_fill
-            # we need a full update when all contacts are filled
-            self.update()
-        self.host.call_listeners("contactsFilled", profile=profile)
-
-    def fill(self, profile=None):
-        """Get all contacts from backend, and fill the widget
-
-        Contacts will be cleared before refilling them
-        @param profile(unicode, None): profile to fill
-            None to fill all profiles
-        """
-        try:
-            to_fill = self._to_fill
-        except AttributeError:
-            to_fill = self._to_fill = set()
-
-        # we check if profiles have already been filled
-        # to void filling them several times
-        filled = to_fill.copy()
-
-        if profile is not None:
-            assert profile in self._clist
-            to_fill.add(profile)
-        else:
-            to_fill.update(list(self._clist.keys()))
-
-        remaining = to_fill.difference(filled)
-        if remaining != to_fill:
-            log.debug(
-                "Not re-filling already filled contact list(s) for {}".format(
-                    ", ".join(to_fill.intersection(filled))
-                )
-            )
-        for profile in remaining:
-            self._clist[profile]._fill()
-
-    def clear_contacts(self, keep_cache=False):
-        """Clear all the contact list
-
-        @param keep_cache: if True, don't reset the cache
-        """
-        for contact_list in self._clist.values():
-            contact_list.clear_contacts(keep_cache)
-        # we need a full update
-        self.update()
-
-    def select(self, entity):
-        for contact_list in self._clist.values():
-            contact_list.select(entity)
-
-    def unselect(self, entity):
-        for contact_list in self._clist.values():
-            contact_list.select(entity)
-
-    def lock_update(self, locked=True, do_update=True):
-        """Forbid contact list updates
-
-        Used mainly while profiles are plugged, as many updates can occurs, causing
-        an impact on performances
-        @param locked(bool): updates are forbidden if True
-        @param do_update(bool): if True, a full update is done after unlocking
-            if set to False, widget state can be inconsistent, be sure to know
-            what youa re doing!
-        """
-        log.debug(
-            "Contact lists updates are now {}".format(
-                "LOCKED" if locked else "UNLOCKED"
-            )
-        )
-        self._update_locked = locked
-        if not locked and do_update:
-            self.update()
-
-    def update(self, entities=None, type_=None, profile=None):
-        if not self._update_locked:
-            for widget in self._widgets:
-                widget.update(entities, type_, profile)
-
-
-class QuickContactList(QuickWidget):
-    """This class manage the visual representation of contacts"""
-
-    SINGLE = False
-    PROFILES_MULTIPLE = True
-    # Can be linked to no profile (e.g. at the early frontend start)
-    PROFILES_ALLOW_NONE = True
-
-    def __init__(self, host, profiles):
-        super(QuickContactList, self).__init__(host, None, profiles)
-
-        # options
-        # for next values, None means use indivual value per profile
-        # True or False mean override these values for all profiles
-        self.show_disconnected = None  # TODO
-        self._show_empty_groups = None  # TODO
-        self.show_resources = None  # TODO
-        self.show_status = None  # TODO
-
-    def post_init(self):
-        """Method to be called by frontend after widget is initialised"""
-        handler.register(self)
-
-    @property
-    def all_iter(self):
-        return handler.all_iter
-
-    @property
-    def items(self):
-        return handler.items
-
-    @property
-    def items_sorted(self):
-        return handler.items_sorted
-
-    def update(self, entities=None, type_=None, profile=None):
-        """Update the display when something changed
-
-        @param entities(iterable[jid.JID], None): updated entities,
-            None to update the whole contact list
-        @param type_(unicode, None): update type, may be:
-            - C.UPDATE_DELETE: entity deleted
-            - C.UPDATE_MODIFY: entity updated
-            - C.UPDATE_ADD: entity added
-            - C.UPDATE_SELECTION: selection modified
-            - C.UPDATE_STRUCTURE: organisation of items is modified (not items
-              themselves)
-            or None for undefined update
-            Note that events correspond to addition, modification and deletion
-            of items on the whole contact list. If the contact is visible or not
-            has no influence on the type_.
-        @param profile(unicode, None): profile concerned with the update
-            None if all profiles need to be updated
-        """
-        raise NotImplementedError
-
-    def on_delete(self):
-        QuickWidget.on_delete(self)
-        handler.unregister(self)
--- a/sat_frontends/quick_frontend/quick_contact_management.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-#!/usr/bin/env python3
-
-
-# helper class for making a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-from libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-from sat_frontends.tools.jid import JID
-
-
-class QuickContactManagement(object):
-    """This helper class manage the contacts and ease the use of nicknames and shortcuts"""
-
-    ### FIXME: is SàT a better place for all this stuff ??? ###
-
-    def __init__(self):
-        self.__contactlist = {}
-
-    def __contains__(self, entity):
-        return entity.bare in self.__contactlist
-
-    def clear(self):
-        """Clear all the contact list"""
-        self.__contactlist.clear()
-
-    def add(self, entity):
-        """Add contact to the list, update resources"""
-        if entity.bare not in self.__contactlist:
-            self.__contactlist[entity.bare] = {"resources": []}
-        if not entity.resource:
-            return
-        if entity.resource in self.__contactlist[entity.bare]["resources"]:
-            self.__contactlist[entity.bare]["resources"].remove(entity.resource)
-        self.__contactlist[entity.bare]["resources"].append(entity.resource)
-
-    def get_cont_from_group(self, group):
-        """Return all contacts which are in given group"""
-        result = []
-        for contact in self.__contactlist:
-            if "groups" in self.__contactlist[contact]:
-                if group in self.__contactlist[contact]["groups"]:
-                    result.append(JID(contact))
-        return result
-
-    def get_attr(self, entity, name):
-        """Return a specific attribute of contact, or all attributes
-        @param entity: jid of the contact
-        @param name: name of the attribute
-        @return: asked attribute"""
-        if entity.bare in self.__contactlist:
-            if name == "status":  # FIXME: for the moment, we only use the first status
-                if self.__contactlist[entity.bare]["statuses"]:
-                    return list(self.__contactlist[entity.bare]["statuses"].values())[0]
-            if name in self.__contactlist[entity.bare]:
-                return self.__contactlist[entity.bare][name]
-        else:
-            log.debug(_("Trying to get attribute for an unknown contact"))
-        return None
-
-    def is_connected(self, entity):
-        """Tell if the contact is online"""
-        return entity.bare in self.__contactlist
-
-    def remove(self, entity):
-        """remove resource. If no more resource is online or is no resource is specified, contact is deleted"""
-        try:
-            if entity.resource:
-                self.__contactlist[entity.bare]["resources"].remove(entity.resource)
-            if not entity.resource or not self.__contactlist[entity.bare]["resources"]:
-                # no more resource available: the contact seems really disconnected
-                del self.__contactlist[entity.bare]
-        except KeyError:
-            log.error(_("INTERNAL ERROR: Key log.error"))
-            raise
-
-    def update(self, entity, key, value):
-        """Update attribute of contact
-        @param entity: jid of the contact
-        @param key: name of the attribute
-        @param value: value of the attribute
-        """
-        if entity.bare in self.__contactlist:
-            self.__contactlist[entity.bare][key] = value
-        else:
-            log.debug(_("Trying to update an unknown contact: %s") % entity.bare)
-
-    def get_full(self, entity):
-        return entity.bare + "/" + self.__contactlist[entity.bare]["resources"][-1]
--- a/sat_frontends/quick_frontend/quick_game_tarot.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,161 +0,0 @@
-#!/usr/bin/env python3
-
-
-# helper class for making a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-from sat_frontends.tools.jid import JID
-
-
-class QuickTarotGame(object):
-    def __init__(self, parent, referee, players):
-        self._autoplay = None  # XXX: use 0 to activate fake play, None else
-        self.parent = parent
-        self.referee = referee
-        self.players = players
-        self.played = {}
-        for player in players:
-            self.played[player] = None
-        self.player_nick = parent.nick
-        self.bottom_nick = str(self.player_nick)
-        idx = self.players.index(self.player_nick)
-        idx = (idx + 1) % len(self.players)
-        self.right_nick = str(self.players[idx])
-        idx = (idx + 1) % len(self.players)
-        self.top_nick = str(self.players[idx])
-        idx = (idx + 1) % len(self.players)
-        self.left_nick = str(self.players[idx])
-        self.bottom_nick = str(self.player_nick)
-        self.selected = []  # Card choosed by the player (e.g. during ecart)
-        self.hand_size = 13  # number of cards in a hand
-        self.hand = []
-        self.to_show = []
-        self.state = None
-
-    def reset_round(self):
-        """Reset the game's variables to be reatty to start the next round"""
-        del self.selected[:]
-        del self.hand[:]
-        del self.to_show[:]
-        self.state = None
-        for pl in self.played:
-            self.played[pl] = None
-
-    def get_player_location(self, nick):
-        """return player location (top,bottom,left or right)"""
-        for location in ["top", "left", "bottom", "right"]:
-            if getattr(self, "%s_nick" % location) == nick:
-                return location
-        assert False
-
-    def load_cards(self):
-        """Load all the cards in memory
-        @param dir: directory where the PNG files are"""
-        self.cards = {}
-        self.deck = []
-        self.cards[
-            "atout"
-        ] = {}  # As Tarot is a french game, it's more handy & logical to keep french names
-        self.cards["pique"] = {}  # spade
-        self.cards["coeur"] = {}  # heart
-        self.cards["carreau"] = {}  # diamond
-        self.cards["trefle"] = {}  # club
-
-    def tarot_game_new_handler(self, hand):
-        """Start a new game, with given hand"""
-        assert len(self.hand) == 0
-        for suit, value in hand:
-            self.hand.append(self.cards[suit, value])
-        self.hand.sort()
-        self.state = "init"
-
-    def tarot_game_choose_contrat_handler(self, xml_data):
-        """Called when the player as to select his contrat
-        @param xml_data: SàT xml representation of the form"""
-        raise NotImplementedError
-
-    def tarot_game_show_cards_handler(self, game_stage, cards, data):
-        """Display cards in the middle of the game (to show for e.g. chien ou poignée)"""
-        self.to_show = []
-        for suit, value in cards:
-            self.to_show.append(self.cards[suit, value])
-        if game_stage == "chien" and data["attaquant"] == self.player_nick:
-            self.state = "wait_for_ecart"
-        else:
-            self.state = "chien"
-
-    def tarot_game_your_turn_handler(self):
-        """Called when we have to play :)"""
-        if self.state == "chien":
-            self.to_show = []
-        self.state = "play"
-        self.__fake_play()
-
-    def __fake_play(self):
-        """Convenience method for stupid autoplay
-        /!\ don't forgot to comment any interactive dialog for invalid card"""
-        if self._autoplay == None:
-            return
-        if self._autoplay >= len(self.hand):
-            self._autoplay = 0
-        card = self.hand[self._autoplay]
-        self.parent.host.bridge.tarot_game_play_cards(
-            self.player_nick, self.referee, [(card.suit, card.value)], self.parent.profile
-        )
-        del self.hand[self._autoplay]
-        self.state = "wait"
-        self._autoplay += 1
-
-    def tarot_game_score_handler(self, xml_data, winners, loosers):
-        """Called at the end of a game
-        @param xml_data: SàT xml representation of the scores
-        @param winners: list of winners' nicks
-        @param loosers: list of loosers' nicks"""
-        raise NotImplementedError
-
-    def tarot_game_cards_played_handler(self, player, cards):
-        """A card has been played by player"""
-        if self.to_show:
-            self.to_show = []
-        pl_cards = []
-        if self.played[player] != None:  # FIXME
-            for pl in self.played:
-                self.played[pl] = None
-        for suit, value in cards:
-            pl_cards.append(self.cards[suit, value])
-        self.played[player] = pl_cards[0]
-
-    def tarot_game_invalid_cards_handler(self, phase, played_cards, invalid_cards):
-        """Invalid cards have been played
-        @param phase: phase of the game
-        @param played_cards: all the cards played
-        @param invalid_cards: cards which are invalid"""
-
-        if phase == "play":
-            self.state = "play"
-        elif phase == "ecart":
-            self.state = "ecart"
-        else:
-            log.error("INTERNAL ERROR: unmanaged game phase")
-
-        for suit, value in played_cards:
-            self.hand.append(self.cards[suit, value])
-
-        self.hand.sort()
-        self.__fake_play()
--- a/sat_frontends/quick_frontend/quick_games.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,153 +0,0 @@
-#!/usr/bin/env python3
-
-
-# helper class for making a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-
-from libervia.backend.core.i18n import _
-
-from sat_frontends.tools import jid
-from sat_frontends.tools import games
-from sat_frontends.quick_frontend.constants import Const as C
-
-from . import quick_chat
-
-
-class RoomGame(object):
-    _game_name = None
-    _signal_prefix = None
-    _signal_suffixes = None
-
-    @classmethod
-    def register_signals(cls, host):
-        def make_handler(suffix, signal):
-            def handler(*args):
-                if suffix in ("Started", "Players"):
-                    return cls.started_handler(host, suffix, *args)
-                return cls.generic_handler(host, signal, *args)
-
-            return handler
-
-        for suffix in cls._signal_suffixes:
-            signal = cls._signal_prefix + suffix
-            host.register_signal(
-                signal, handler=make_handler(suffix, signal), iface="plugin"
-            )
-
-    @classmethod
-    def started_handler(cls, host, suffix, *args):
-        room_jid, args, profile = jid.JID(args[0]), args[1:-1], args[-1]
-        referee, players, args = args[0], args[1], args[2:]
-        chat_widget = host.widgets.get_or_create_widget(
-            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
-        )
-
-        # update symbols
-        if cls._game_name not in chat_widget.visible_states:
-            chat_widget.visible_states.append(cls._game_name)
-        symbols = games.SYMBOLS[cls._game_name]
-        index = 0
-        contact_list = host.contact_lists[profile]
-        for occupant in chat_widget.occupants:
-            occupant_jid = jid.new_resource(room_jid, occupant)
-            contact_list.set_cache(
-                occupant_jid,
-                cls._game_name,
-                symbols[index % len(symbols)] if occupant in players else None,
-            )
-            chat_widget.update(occupant_jid)
-
-        if suffix == "Players" or chat_widget.nick not in players:
-            return  # waiting for other players to join, or not playing
-        if cls._game_name in chat_widget.games:
-            return  # game panel is already there
-        real_class = host.widgets.get_real_class(cls)
-        if real_class == cls:
-            host.show_dialog(
-                _(
-                    "A {game} activity between {players} has been started, but you couldn't take part because your client doesn't support it."
-                ).format(game=cls._game_name, players=", ".join(players)),
-                _("{game} Game").format(game=cls._game_name),
-            )
-            return
-        panel = real_class(chat_widget, referee, players, *args)
-        chat_widget.games[cls._game_name] = panel
-        chat_widget.add_game_panel(panel)
-
-    @classmethod
-    def generic_handler(cls, host, signal, *args):
-        room_jid, args, profile = jid.JID(args[0]), args[1:-1], args[-1]
-        chat_widget = host.widgets.get_widget(quick_chat.QuickChat, room_jid, profile)
-        if chat_widget:
-            try:
-                game_panel = chat_widget.games[cls._game_name]
-            except KeyError:
-                log.error(
-                    "TODO: better game synchronisation - received signal %s but no panel is found"
-                    % signal
-                )
-                return
-            else:
-                getattr(game_panel, "%sHandler" % signal)(*args)
-
-
-class Tarot(RoomGame):
-    _game_name = "Tarot"
-    _signal_prefix = "tarotGame"
-    _signal_suffixes = (
-        "Started",
-        "Players",
-        "New",
-        "ChooseContrat",
-        "ShowCards",
-        "YourTurn",
-        "Score",
-        "CardsPlayed",
-        "InvalidCards",
-    )
-
-
-class Quiz(RoomGame):
-    _game_name = "Quiz"
-    _signal_prefix = "quizGame"
-    _signal_suffixes = (
-        "Started",
-        "New",
-        "Question",
-        "PlayerBuzzed",
-        "PlayerSays",
-        "AnswerResult",
-        "TimerExpired",
-        "TimerRestarted",
-    )
-
-
-class Radiocol(RoomGame):
-    _game_name = "Radiocol"
-    _signal_prefix = "radiocol"
-    _signal_suffixes = (
-        "Started",
-        "Players",
-        "SongRejected",
-        "Preload",
-        "Play",
-        "NoUpload",
-        "UploadOk",
-    )
--- a/sat_frontends/quick_frontend/quick_list_manager.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,72 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2013-2016 Adrien Cossa <souliane@mailoo.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/>.
-
-
-class QuickTagList(object):
-    """This class manages a sorted list of tagged items, and a complementary sorted list of suggested but non tagged items."""
-
-    def __init__(self, items=None):
-        """
-
-        @param items (list): the suggested list of non tagged items
-        """
-        self.tagged = []
-        self.original = (
-            items[:] if items else []
-        )  # XXX: copy the list! It will be modified
-        self.untagged = (
-            items[:] if items else []
-        )  # XXX: copy the list! It will be modified
-        self.untagged.sort()
-
-    @property
-    def items(self):
-        """Return a sorted list of all items, tagged or untagged.
-        
-        @return list
-        """
-        res = list(set(self.tagged).union(self.untagged))
-        res.sort()
-        return res
-
-    def tag(self, items):
-        """Tag some items.
-
-        @param items (list): items to be tagged
-        """
-        for item in items:
-            if item not in self.tagged:
-                self.tagged.append(item)
-            if item in self.untagged:
-                self.untagged.remove(item)
-        self.tagged.sort()
-        self.untagged.sort()
-
-    def untag(self, items):
-        """Untag some items.
-  
-        @param items (list): items to be untagged
-        """
-        for item in items:
-            if item not in self.untagged and item in self.original:
-                self.untagged.append(item)
-            if item in self.tagged:
-                self.tagged.remove(item)
-        self.tagged.sort()
-        self.untagged.sort()
--- a/sat_frontends/quick_frontend/quick_menus.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,491 +0,0 @@
-#!/usr/bin/env python3
-
-
-# helper class for making a SAT frontend
-# Copyright (C) 2009-2021 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/>.
-
-try:
-    # FIXME: to be removed when an acceptable solution is here
-    str("")  # XXX: unicode doesn't exist in pyjamas
-except (
-    TypeError,
-    AttributeError,
-):  # Error raised is not the same depending on pyjsbuild options
-    str = str
-
-from libervia.backend.core.log import getLogger
-from libervia.backend.core.i18n import _, language_switch
-
-log = getLogger(__name__)
-from sat_frontends.quick_frontend.constants import Const as C
-from collections import OrderedDict
-
-
-## items ##
-
-
-class MenuBase(object):
-    ACTIVE = True
-
-    def __init__(self, name, extra=None):
-        """
-        @param name(unicode): canonical name of the item
-        @param extra(dict[unicode, unicode], None): same as in [add_menus]
-        """
-        self._name = name
-        self.set_extra(extra)
-
-    @property
-    def canonical(self):
-        """Return the canonical name of the container, used to identify it"""
-        return self._name
-
-    @property
-    def name(self):
-        """Return the name of the container, can be translated"""
-        return self._name
-
-    def set_extra(self, extra):
-        if extra is None:
-            extra = {}
-        self.icon = extra.get("icon")
-
-
-class MenuItem(MenuBase):
-    """A callable item in the menu"""
-
-    CALLABLE = False
-
-    def __init__(self, name, name_i18n, extra=None, type_=None):
-        """
-        @param name(unicode): canonical name of the item
-        @param name_i18n(unicode): translated name of the item
-        @param extra(dict[unicode, unicode], None): same as in [add_menus]
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        """
-        MenuBase.__init__(self, name, extra)
-        self._name_i18n = name_i18n if name_i18n else name
-        self.type = type_
-
-    @property
-    def name(self):
-        return self._name_i18n
-
-    def collect_data(self, caller):
-        """Get data according to data_collector
-
-        @param caller: Menu caller
-        """
-        assert self.type is not None  # if data collector are used, type must be set
-        data_collector = QuickMenusManager.get_data_collector(self.type)
-
-        if data_collector is None:
-            return {}
-
-        elif callable(data_collector):
-            return data_collector(caller, self.name)
-
-        else:
-            if caller is None:
-                log.error("Caller can't be None with a dictionary as data_collector")
-                return {}
-            data = {}
-            for data_key, caller_attr in data_collector.items():
-                data[data_key] = str(getattr(caller, caller_attr))
-            return data
-
-    def call(self, caller, profile=C.PROF_KEY_NONE):
-        """Execute the menu item
-
-        @param caller: instance linked to the menu
-        @param profile: %(doc_profile)s
-        """
-        raise NotImplementedError
-
-
-class MenuItemDistant(MenuItem):
-    """A MenuItem with a distant callback"""
-
-    CALLABLE = True
-
-    def __init__(self, host, type_, name, name_i18n, id_, extra=None):
-        """
-        @param host: %(doc_host)s
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        @param name(unicode): canonical name of the item
-        @param name_i18n(unicode): translated name of the item
-        @param id_(unicode): id of the distant callback
-        @param extra(dict[unicode, unicode], None): same as in [add_menus]
-        """
-        MenuItem.__init__(self, name, name_i18n, extra, type_)
-        self.host = host
-        self.id = id_
-
-    def call(self, caller, profile=C.PROF_KEY_NONE):
-        data = self.collect_data(caller)
-        log.debug("data collected: %s" % data)
-        self.host.action_launch(self.id, data, profile=profile)
-
-
-class MenuItemLocal(MenuItem):
-    """A MenuItem with a local callback"""
-
-    CALLABLE = True
-
-    def __init__(self, type_, name, name_i18n, callback, extra=None):
-        """
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        @param name(unicode): canonical name of the item
-        @param name_i18n(unicode): translated name of the item
-        @param callback(callable): local callback.
-            Will be called with no argument if data_collector is None
-            and with caller, profile, and requested data otherwise
-        @param extra(dict[unicode, unicode], None): same as in [add_menus]
-        """
-        MenuItem.__init__(self, name, name_i18n, extra, type_)
-        self.callback = callback
-
-    def call(self, caller, profile=C.PROF_KEY_NONE):
-        data_collector = QuickMenusManager.get_data_collector(self.type)
-        if data_collector is None:
-            # FIXME: would not it be better if caller and profile where used as arguments?
-            self.callback()
-        else:
-            self.callback(caller, self.collect_data(caller), profile)
-
-
-class MenuHook(MenuItemLocal):
-    """A MenuItem which replace an expected item from backend"""
-
-    pass
-
-
-class MenuPlaceHolder(MenuItem):
-    """A non existant menu which is used to keep a position"""
-
-    ACTIVE = False
-
-    def __init__(self, name):
-        MenuItem.__init__(self, name, name)
-
-
-class MenuSeparator(MenuItem):
-    """A separation between items/categories"""
-
-    SEP_IDX = 0
-
-    def __init__(self):
-        MenuSeparator.SEP_IDX += 1
-        name = "___separator_{}".format(MenuSeparator.SEP_IDX)
-        MenuItem.__init__(self, name, name)
-
-
-## containers ##
-
-
-class MenuContainer(MenuBase):
-    def __init__(self, name, extra=None):
-        MenuBase.__init__(self, name, extra)
-        self._items = OrderedDict()
-
-    def __len__(self):
-        return len(self._items)
-
-    def __contains__(self, item):
-        return item.canonical in self._items
-
-    def __iter__(self):
-        return iter(self._items.values())
-
-    def __getitem__(self, item):
-        try:
-            return self._items[item.canonical]
-        except KeyError:
-            raise KeyError(item)
-
-    def get_or_create(self, item):
-        log.debug(
-            "MenuContainer get_or_create: item=%s name=%s\nlist=%s"
-            % (item, item.canonical, list(self._items.keys()))
-        )
-        try:
-            return self[item]
-        except KeyError:
-            self.append(item)
-            return item
-
-    def get_active_menus(self):
-        """Return an iterator on active children"""
-        for child in self._items.values():
-            if child.ACTIVE:
-                yield child
-
-    def append(self, item):
-        """add an item at the end of current ones
-
-        @param item: instance of MenuBase (must be unique in container)
-        """
-        assert isinstance(item, MenuItem) or isinstance(item, MenuContainer)
-        assert item.canonical not in self._items
-        self._items[item.canonical] = item
-
-    def replace(self, item):
-        """add an item at the end of current ones or replace an existing one"""
-        self._items[item.canonical] = item
-
-
-class MenuCategory(MenuContainer):
-    """A category which can hold other menus or categories"""
-
-    def __init__(self, name, name_i18n=None, extra=None):
-        """
-        @param name(unicode): canonical name
-        @param name_i18n(unicode, None): translated name
-        @param icon(unicode, None): same as in MenuBase.__init__
-        """
-        log.debug("creating menuCategory %s with extra %s" % (name, extra))
-        MenuContainer.__init__(self, name, extra)
-        self._name_i18n = name_i18n or name
-
-    @property
-    def name(self):
-        return self._name_i18n
-
-
-class MenuType(MenuContainer):
-    """A type which can hold other menus or categories"""
-
-    pass
-
-
-## manager ##
-
-
-class QuickMenusManager(object):
-    """Manage all the menus"""
-
-    _data_collectors = {
-        C.MENU_GLOBAL: None
-    }  # No data is associated with C.MENU_GLOBAL items
-
-    def __init__(self, host, menus=None, language=None):
-        """
-        @param host: %(doc_host)s
-        @param menus(iterable): menus as in [add_menus]
-        @param language: same as in [i18n.language_switch]
-        """
-        self.host = host
-        MenuBase.host = host
-        self.language = language
-        self.menus = {}
-        if menus is not None:
-            self.add_menus(menus)
-
-    def _get_path_i_1_8_n(self, path):
-        """Return translated version of path"""
-        language_switch(self.language)
-        path_i18n = [_(elt) for elt in path]
-        language_switch()
-        return path_i18n
-
-    def _create_categories(self, type_, path, path_i18n=None, top_extra=None):
-        """Create catogories of the path
-
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
-        @param path_i18n(list[unicode], None):  translated menu path (same lenght as path) or None to get deferred translation of path
-        @param top_extra: extra data to use on the first element of path only. If the first element already exists and is reused, top_extra will be ignored (you'll have to manually change it if you really want to).
-        @return (MenuContainer): last category created, or MenuType if path is empty
-        """
-        if path_i18n is None:
-            path_i18n = self._get_path_i_1_8_n(path)
-        assert len(path) == len(path_i18n)
-        menu_container = self.menus.setdefault(type_, MenuType(type_))
-
-        for idx, category in enumerate(path):
-            menu_category = MenuCategory(category, path_i18n[idx], extra=top_extra)
-            menu_container = menu_container.get_or_create(menu_category)
-            top_extra = None
-
-        return menu_container
-
-    @staticmethod
-    def add_data_collector(type_, data_collector):
-        """Associate a data collector to a menu type
-
-        A data collector is a method or a map which allow to collect context data to construct the dictionnary which will be sent to the bridge method managing the menu item.
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        @param data_collector(dict[unicode,unicode], callable, None): can be:
-            - a dict which map data name to local name.
-                The attribute named after the dict values will be getted from caller, and put in data.
-                e.g.: if data_collector={'room_jid':'target'}, then the "room_jid" data will be the value of the "target" attribute of the caller.
-            - a callable which must return the data dictionnary. callable will have caller and item name as argument
-            - None: an empty dict will be used
-        """
-        QuickMenusManager._data_collectors[type_] = data_collector
-
-    @staticmethod
-    def get_data_collector(type_):
-        """Get data_collector associated to type_
-
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        @return (callable, dict, None): data_collector
-        """
-        try:
-            return QuickMenusManager._data_collectors[type_]
-        except KeyError:
-            log.error("No data collector registered for {}".format(type_))
-            return None
-
-    def add_menu_item(self, type_, path, item, path_i18n=None, top_extra=None):
-        """Add a MenuItemBase instance
-
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu], stop at the last parent category
-        @param item(MenuItem): a instancied item
-        @param path_i18n(list[unicode],None):  translated menu path (same lenght as path) or  None to use deferred translation of path
-        @param top_extra: same as in [_create_categories]
-        """
-        if path_i18n is None:
-            path_i18n = self._get_path_i_1_8_n(path)
-        assert path and len(path) == len(path_i18n)
-
-        menu_container = self._create_categories(type_, path, path_i18n, top_extra)
-
-        if item in menu_container:
-            if isinstance(item, MenuHook):
-                menu_container.replace(item)
-            else:
-                container_item = menu_container[item]
-                if isinstance(container_item, MenuPlaceHolder):
-                    menu_container.replace(item)
-                elif isinstance(container_item, MenuHook):
-                    # MenuHook must not be replaced
-                    log.debug(
-                        "ignoring menu at path [{}] because a hook is already in place".format(
-                            path
-                        )
-                    )
-                else:
-                    log.error("Conflicting menus at path [{}]".format(path))
-        else:
-            log.debug("Adding menu [{type_}] {path}".format(type_=type_, path=path))
-            menu_container.append(item)
-            self.host.call_listeners("menu", type_, path, path_i18n, item)
-
-    def add_menu(
-        self,
-        type_,
-        path,
-        path_i18n=None,
-        extra=None,
-        top_extra=None,
-        id_=None,
-        callback=None,
-    ):
-        """Add a menu item
-
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
-        @param path_i18n(list[unicode], None):  translated menu path (same lenght as path), or None to get deferred translation
-        @param extra(dict[unicode, unicode], None): same as in [add_menus]
-        @param top_extra: same as in [_create_categories]
-        @param id_(unicode): callback id (mutually exclusive with callback)
-        @param callback(callable): local callback (mutually exclusive with id_)
-        """
-        if path_i18n is None:
-            path_i18n = self._get_path_i_1_8_n(path)
-        assert bool(id_) ^ bool(callback)  # we must have id_ xor callback defined
-        if id_:
-            menu_item = MenuItemDistant(
-                self.host, type_, path[-1], path_i18n[-1], id_=id_, extra=extra
-            )
-        else:
-            menu_item = MenuItemLocal(
-                type_, path[-1], path_i18n[-1], callback=callback, extra=extra
-            )
-        self.add_menu_item(type_, path[:-1], menu_item, path_i18n[:-1], top_extra)
-
-    def add_menus(self, menus, top_extra=None):
-        """Add several menus at once
-
-        @param menus(iterable): iterable with:
-            id_(unicode,callable): id of distant callback or local callback
-            type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-            path(iterable[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
-            path_i18n(iterable[unicode]):  translated menu path (same lenght as path)
-            extra(dict[unicode,unicode]): dictionary of extra data (used on the leaf menu), can be:
-                - "icon": icon name
-        @param top_extra: same as in [_create_categories]
-        """
-        # TODO: manage icons
-        for id_, type_, path, path_i18n, extra in menus:
-            if callable(id_):
-                self.add_menu(
-                    type_, path, path_i18n, callback=id_, extra=extra, top_extra=top_extra
-                )
-            else:
-                self.add_menu(
-                    type_, path, path_i18n, id_=id_, extra=extra, top_extra=top_extra
-                )
-
-    def add_menu_hook(
-        self, type_, path, path_i18n=None, extra=None, top_extra=None, callback=None
-    ):
-        """Helper method to add a menu hook
-
-        Menu hooks are local menus which override menu given by backend
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
-        @param path_i18n(list[unicode], None):  translated menu path (same lenght as path), or None to get deferred translation
-        @param extra(dict[unicode, unicode], None): same as in [add_menus]
-        @param top_extra: same as in [_create_categories]
-        @param callback(callable): local callback (mutually exclusive with id_)
-        """
-        if path_i18n is None:
-            path_i18n = self._get_path_i_1_8_n(path)
-        menu_item = MenuHook(
-            type_, path[-1], path_i18n[-1], callback=callback, extra=extra
-        )
-        self.add_menu_item(type_, path[:-1], menu_item, path_i18n[:-1], top_extra)
-        log.info("Menu hook set on {path} ({type_})".format(path=path, type_=type_))
-
-    def add_category(self, type_, path, path_i18n=None, extra=None, top_extra=None):
-        """Create a category with all parents, and set extra on the last one
-
-        @param type_(unicode): same as in [sat.core.sat_main.SAT.import_menu]
-        @param path(list[unicode]):  same as in [sat.core.sat_main.SAT.import_menu]
-        @param path_i18n(list[unicode], None):  translated menu path (same lenght as path), or None to get deferred translation of path
-        @param extra(dict[unicode, unicode], None): same as in [add_menus] (added on the leaf category only)
-        @param top_extra: same as in [_create_categories]
-        @return (MenuCategory): last category add
-        """
-        if path_i18n is None:
-            path_i18n = self._get_path_i_1_8_n(path)
-        last_container = self._create_categories(
-            type_, path, path_i18n, top_extra=top_extra
-        )
-        last_container.set_extra(extra)
-        return last_container
-
-    def get_main_container(self, type_):
-        """Get a main MenuType container
-
-        @param type_: a C.MENU_* constant
-        @return(MenuContainer): the main container
-        """
-        menu_container = self.menus.setdefault(type_, MenuType(type_))
-        return menu_container
--- a/sat_frontends/quick_frontend/quick_profile_manager.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,291 +0,0 @@
-#!/usr/bin/env python3
-
-
-# helper class for making a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-from libervia.backend.core import log as logging
-
-log = logging.getLogger(__name__)
-from sat_frontends.primitivus.constants import Const as C
-
-
-class ProfileRecord(object):
-    """Class which manage data for one profile"""
-
-    def __init__(self, profile=None, login=None, password=None):
-        self._profile = profile
-        self._login = login
-        self._password = password
-
-    @property
-    def profile(self):
-        return self._profile
-
-    @profile.setter
-    def profile(self, value):
-        self._profile = value
-        # if we change the profile,
-        # we must have no login/password until backend give them
-        self._login = self._password = None
-
-    @property
-    def login(self):
-        return self._login
-
-    @login.setter
-    def login(self, value):
-        self._login = value
-
-    @property
-    def password(self):
-        return self._password
-
-    @password.setter
-    def password(self, value):
-        self._password = value
-
-
-class QuickProfileManager(object):
-    """Class with manage profiles creation/deletion/connection"""
-
-    def __init__(self, host, autoconnect=None):
-        """Create the manager
-
-        @param host: %(doc_host)s
-        @param autoconnect(iterable): list of profiles to connect automatically
-        """
-        self.host = host
-        self._autoconnect = bool(autoconnect)
-        self.current = ProfileRecord()
-
-    def go(self, autoconnect):
-        if self._autoconnect:
-            self.autoconnect(autoconnect)
-
-    def autoconnect(self, profile_keys):
-        """Automatically connect profiles
-
-        @param profile_keys(iterable): list of profile keys to connect
-        """
-        if not profile_keys:
-            log.warning("No profile given to autoconnect")
-            return
-        self._autoconnect = True
-        self._autoconnect_profiles = []
-        self._do_autoconnect(profile_keys)
-
-    def _do_autoconnect(self, profile_keys):
-        """Connect automatically given profiles
-
-        @param profile_kes(iterable): profiles to connect
-        """
-        assert self._autoconnect
-
-        def authenticate_cb(data, cb_id, profile):
-
-            if C.bool(data.pop("validated", C.BOOL_FALSE)):
-                self._autoconnect_profiles.append(profile)
-                if len(self._autoconnect_profiles) == len(profile_keys):
-                    # all the profiles have been validated
-                    self.host.plug_profiles(self._autoconnect_profiles)
-            else:
-                # a profile is not validated, we go to manual mode
-                self._autoconnect = False
-            self.host.action_manager(data, callback=authenticate_cb, profile=profile)
-
-        def get_profile_name_cb(profile):
-            if not profile:
-                # FIXME: this method is not handling manual mode correclty anymore
-                #        must be thought to be handled asynchronously
-                self._autoconnect = False  # manual mode
-                msg = _("Trying to plug an unknown profile key ({})".format(profile_key))
-                log.warning(msg)
-                self.host.show_dialog(_("Profile plugging in error"), msg, "error")
-            else:
-                self.host.action_launch(
-                    C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, profile=profile
-                )
-
-        def get_profile_name_eb(failure):
-            log.error("Can't retrieve profile name: {}".format(failure))
-
-        for profile_key in profile_keys:
-            self.host.bridge.profile_name_get(
-                profile_key, callback=get_profile_name_cb, errback=get_profile_name_eb
-            )
-
-    def get_param_error(self, __):
-        self.host.show_dialog(_("Error"), _("Can't get profile parameter"), "error")
-
-    ## Helping methods ##
-
-    def _get_error_message(self, reason):
-        """Return an error message corresponding to profile creation error
-
-        @param reason (str): reason as returned by profile_create
-        @return (unicode): human readable error message
-        """
-        if reason == "ConflictError":
-            message = _("A profile with this name already exists")
-        elif reason == "CancelError":
-            message = _("Profile creation cancelled by backend")
-        elif reason == "ValueError":
-            message = _(
-                "You profile name is not valid"
-            )  # TODO: print a more informative message (empty name, name starting with '@')
-        else:
-            message = _("Can't create profile ({})").format(reason)
-        return message
-
-    def _delete_profile(self):
-        """Delete the currently selected profile"""
-        if self.current.profile:
-            self.host.bridge.profile_delete_async(
-                self.current.profile, callback=self.refill_profiles
-            )
-            self.reset_fields()
-
-    ## workflow methods (events occuring during the profiles selection) ##
-
-    # These methods must be called by the frontend at some point
-
-    def _on_connect_profiles(self):
-        """Connect the profiles and start the main widget"""
-        if self._autoconnect:
-            self.host.show_dialog(
-                _("Internal error"),
-                _("You can't connect manually and automatically at the same time"),
-                "error",
-            )
-            return
-        self.update_connection_params()
-        profiles = self.get_profiles()
-        if not profiles:
-            self.host.show_dialog(
-                _("No profile selected"),
-                _("You need to create and select at least one profile before connecting"),
-                "error",
-            )
-        else:
-            # All profiles in the list are already validated, so we can plug them directly
-            self.host.plug_profiles(profiles)
-
-    def get_connection_params(self, profile):
-        """Get login and password and display them
-
-        @param profile: %(doc_profile)s
-        """
-        self.host.bridge.param_get_a_async(
-            "JabberID",
-            "Connection",
-            profile_key=profile,
-            callback=self.set_jid,
-            errback=self.get_param_error,
-        )
-        self.host.bridge.param_get_a_async(
-            "Password",
-            "Connection",
-            profile_key=profile,
-            callback=self.set_password,
-            errback=self.get_param_error,
-        )
-
-    def update_connection_params(self):
-        """Check if connection parameters have changed, and update them if so"""
-        if self.current.profile:
-            login = self.get_jid()
-            password = self.getPassword()
-            if login != self.current.login and self.current.login is not None:
-                self.current.login = login
-                self.host.bridge.param_set(
-                    "JabberID", login, "Connection", profile_key=self.current.profile
-                )
-                log.info("login updated for profile [{}]".format(self.current.profile))
-            if password != self.current.password and self.current.password is not None:
-                self.current.password = password
-                self.host.bridge.param_set(
-                    "Password", password, "Connection", profile_key=self.current.profile
-                )
-                log.info(
-                    "password updated for profile [{}]".format(self.current.profile)
-                )
-
-    ## graphic updates (should probably be overriden in frontends) ##
-
-    def reset_fields(self):
-        """Set profile to None, and reset fields"""
-        self.current.profile = None
-        self.set_jid("")
-        self.set_password("")
-
-    def refill_profiles(self):
-        """Rebuild the list of profiles"""
-        profiles = self.host.bridge.profiles_list_get()
-        profiles.sort()
-        self.set_profiles(profiles)
-
-    ## Method which must be implemented by frontends ##
-
-    # get/set data
-
-    def get_profiles(self):
-        """Return list of selected profiles
-
-        Must be implemented by frontends
-        @return (list): list of profiles
-        """
-        raise NotImplementedError
-
-    def set_profiles(self, profiles):
-        """Update the list of profiles"""
-        raise NotImplementedError
-
-    def get_jid(self):
-        """Get current jid
-
-        Must be implemented by frontends
-        @return (unicode): current jabber id
-        """
-        raise NotImplementedError
-
-    def getPassword(self):
-        """Get current password
-
-        Must be implemented by frontends
-        @return (unicode): current password
-        """
-        raise NotImplementedError
-
-    def set_jid(self, jid_):
-        """Set current jid
-
-        Must be implemented by frontends
-        @param jid_(unicode): jabber id to set
-        """
-        raise NotImplementedError
-
-    def set_password(self, password):
-        """Set current password
-
-        Must be implemented by frontends
-        """
-        raise NotImplementedError
-
-    # dialogs
-
-    # Note: a method which check profiles change must be implemented too
--- a/sat_frontends/quick_frontend/quick_utils.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,54 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-from os.path import exists, splitext
-from optparse import OptionParser
-
-
-def get_new_path(path):
-    """ Check if path exists, and find a non existant path if needed """
-    idx = 2
-    if not exists(path):
-        return path
-    root, ext = splitext(path)
-    while True:
-        new_path = "%s_%d%s" % (root, idx, ext)
-        if not exists(new_path):
-            return new_path
-        idx += 1
-
-
-def check_options():
-    """Check command line options"""
-    usage = _(
-        """
-    %prog [options]
-
-    %prog --help for options list
-    """
-    )
-    parser = OptionParser(usage=usage)  # TODO: use argparse
-
-    parser.add_option("-p", "--profile", help=_("Select the profile to use"))
-
-    (options, args) = parser.parse_args()
-    if options.profile:
-        options.profile = options.profile
-    return options
--- a/sat_frontends/quick_frontend/quick_widgets.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,478 +0,0 @@
-#!/usr/bin/env python3
-
-
-# helper class for making a SAT frontend
-# Copyright (C) 2009-2021 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 libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-from libervia.backend.core import exceptions
-from sat_frontends.quick_frontend.constants import Const as C
-
-
-classes_map = {}
-
-
-try:
-    # FIXME: to be removed when an acceptable solution is here
-    str("")  # XXX: unicode doesn't exist in pyjamas
-except (
-    TypeError,
-    AttributeError,
-):  # Error raised is not the same depending on pyjsbuild options
-    str = str
-
-
-def register(base_cls, child_cls=None):
-    """Register a child class to use by default when a base class is needed
-
-    @param base_cls: "Quick..." base class (like QuickChat or QuickContact), must inherit from QuickWidget
-    @param child_cls: inherited class to use when Quick... class is requested, must inherit from base_cls.
-        Can be None if it's the base_cls itself which register
-    """
-    # FIXME: we use base_cls.__name__ instead of base_cls directly because pyjamas because
-    #        in the second case
-    classes_map[base_cls.__name__] = child_cls
-
-
-class WidgetAlreadyExistsError(Exception):
-    pass
-
-
-class QuickWidgetsManager(object):
-    """This class is used to manage all the widgets of a frontend
-    A widget can be a window, a graphical thing, or someting else depending of the frontend"""
-
-    def __init__(self, host):
-        self.host = host
-        self._widgets = {}
-
-    def __iter__(self):
-        """Iterate throught all widgets"""
-        for widget_map in self._widgets.values():
-            for widget_instances in widget_map.values():
-                for widget in widget_instances:
-                    yield widget
-
-    def get_real_class(self, class_):
-        """Return class registered for given class_
-
-        @param class_: subclass of QuickWidget
-        @return: class actually used to create widget
-        """
-        try:
-            # FIXME: we use base_cls.__name__ instead of base_cls directly because pyjamas bugs
-            #        in the second case
-            cls = classes_map[class_.__name__]
-        except KeyError:
-            cls = class_
-        if cls is None:
-            raise exceptions.InternalError(
-                "There is not class registered for {}".format(class_)
-            )
-        return cls
-
-    def get_widget_instances(self, widget):
-        """Get all instance of a widget
-
-        This is a helper method which call get_widgets
-        @param widget(QuickWidget): retrieve instances of this widget
-        @return: iterator on widgets
-        """
-        return self.get_widgets(widget.__class__, widget.target, widget.profiles)
-
-    def get_widgets(self, class_, target=None, profiles=None, with_duplicates=True):
-        """Get all subclassed widgets instances
-
-        @param class_: subclass of QuickWidget, same parameter as used in
-            [get_or_create_widget]
-        @param target: if not None, construct a hash with this target and filter
-            corresponding widgets
-            recreated widgets are handled
-        @param profiles(iterable, None): if not None, filter on instances linked to these
-            profiles
-        @param with_duplicates(bool): if False, only first widget with a given hash is
-            returned
-        @return: iterator on widgets
-        """
-        class_ = self.get_real_class(class_)
-        try:
-            widgets_map = self._widgets[class_.__name__]
-        except KeyError:
-            return
-        else:
-            if target is not None:
-                filter_hash = str(class_.get_widget_hash(target, profiles))
-            else:
-                filter_hash = None
-            if filter_hash is not None:
-                for widget in widgets_map.get(filter_hash, []):
-                    yield widget
-                    if not with_duplicates:
-                        return
-            else:
-                for widget_instances in widgets_map.values():
-                    for widget in widget_instances:
-                        yield widget
-                        if not with_duplicates:
-                            # widgets are set by hashes, so if don't want duplicates
-                            # we only return the first widget of the list
-                            break
-
-    def get_widget(self, class_, target=None, profiles=None):
-        """Get a widget without creating it if it doesn't exist.
-
-        if several instances of widgets with this hash exist, the first one is returned
-        @param class_: subclass of QuickWidget, same parameter as used in [get_or_create_widget]
-        @param target: target depending of the widget, usually a JID instance
-        @param profiles (unicode, iterable[unicode], None): profile(s) to use (may or may not be
-            used, depending of the widget class)
-        @return: a class_ instance or None if the widget doesn't exist
-        """
-        assert (target is not None) or (profiles is not None)
-        if profiles is not None and isinstance(profiles, str):
-            profiles = [profiles]
-        class_ = self.get_real_class(class_)
-        hash_ = class_.get_widget_hash(target, profiles)
-        try:
-            return self._widgets[class_.__name__][hash_][0]
-        except KeyError:
-            return None
-
-    def get_or_create_widget(self, class_, target, *args, **kwargs):
-        """Get an existing widget or create a new one when necessary
-
-        If the widget is new, self.host.new_widget will be called with it.
-        @param class_(class): class of the widget to create
-        @param target: target depending of the widget, usually a JID instance
-        @param args(list): optional args to create a new instance of class_
-        @param kwargs(dict): optional kwargs to create a new instance of class_
-            if 'profile' key is present, it will be popped and put in 'profiles'
-            if there is neither 'profile' nor 'profiles', None will be used for 'profiles'
-            if 'on_new_widget' is present it can have the following values:
-                C.WIDGET_NEW [default]: self.host.new_widget will be called on widget creation
-                [callable]: this method will be called instead of self.host.new_widget
-                None: do nothing
-            if 'on_existing_widget' is present it can have the following values:
-                C.WIDGET_KEEP  [default]: return the existing widget
-                C.WIDGET_RAISE: raise WidgetAlreadyExistsError
-                C.WIDGET_RECREATE: create a new widget
-                    if the existing widget has a "recreate_args" method, it will be called with args list and kwargs dict
-                    so the values can be completed to create correctly the new instance
-                [callable]: this method will be called with existing widget as argument, the widget to use must be returned
-            if 'force_hash' is present, the hash given in value will be used instead of the one returned by class_.get_widget_hash
-            other keys will be used to instanciate class_ if the case happen (e.g. if type_ is present and class_ is a QuickChat subclass,
-                it will be used to create a new QuickChat instance).
-        @return: a class_ instance, either new or already existing
-        """
-        cls = self.get_real_class(class_)
-
-        ## arguments management ##
-        _args = [self.host, target] + list(
-            args
-        ) or []  # FIXME: check if it's really necessary to use optional args
-        _kwargs = kwargs or {}
-        if "profiles" in _kwargs and "profile" in _kwargs:
-            raise ValueError(
-                "You can't have 'profile' and 'profiles' keys at the same time"
-            )
-        try:
-            _kwargs["profiles"] = [_kwargs.pop("profile")]
-        except KeyError:
-            if not "profiles" in _kwargs:
-                _kwargs["profiles"] = None
-
-        # on_new_widget tells what to do for the new widget creation
-        try:
-            on_new_widget = _kwargs.pop("on_new_widget")
-        except KeyError:
-            on_new_widget = C.WIDGET_NEW
-
-        # on_existing_widget tells what to do when the widget already exists
-        try:
-            on_existing_widget = _kwargs.pop("on_existing_widget")
-        except KeyError:
-            on_existing_widget = C.WIDGET_KEEP
-
-        ## we get the hash ##
-        try:
-            hash_ = _kwargs.pop("force_hash")
-        except KeyError:
-            hash_ = cls.get_widget_hash(target, _kwargs["profiles"])
-
-        ## widget creation or retrieval ##
-
-        widgets_map = self._widgets.setdefault(
-            cls.__name__, {}
-        )  # we sorts widgets by classes
-        if not cls.SINGLE:
-            widget = None  # if the class is not SINGLE, we always create a new widget
-        else:
-            try:
-                widget = widgets_map[hash_][0]
-            except KeyError:
-                widget = None
-            else:
-                widget.add_target(target)
-
-        if widget is None:
-            # we need to create a new widget
-            log.debug(f"Creating new widget for target {target} {cls}")
-            widget = cls(*_args, **_kwargs)
-            widgets_map.setdefault(hash_, []).append(widget)
-            self.host.call_listeners("widgetNew", widget)
-
-            if on_new_widget == C.WIDGET_NEW:
-                self.host.new_widget(widget)
-            elif callable(on_new_widget):
-                on_new_widget(widget)
-            else:
-                assert on_new_widget is None
-        else:
-            # the widget already exists
-            if on_existing_widget == C.WIDGET_KEEP:
-                pass
-            elif on_existing_widget == C.WIDGET_RAISE:
-                raise WidgetAlreadyExistsError(hash_)
-            elif on_existing_widget == C.WIDGET_RECREATE:
-                try:
-                    recreate_args = widget.recreate_args
-                except AttributeError:
-                    pass
-                else:
-                    recreate_args(_args, _kwargs)
-                widget = cls(*_args, **_kwargs)
-                widgets_map[hash_].append(widget)
-                log.debug("widget <{wid}> already exists, a new one has been recreated"
-                    .format(wid=widget))
-            elif callable(on_existing_widget):
-                widget = on_existing_widget(widget)
-                if widget is None:
-                    raise exceptions.InternalError(
-                        "on_existing_widget method must return the widget to use")
-                if widget not in widgets_map[hash_]:
-                    log.debug(
-                        "the widget returned by on_existing_widget is new, adding it")
-                    widgets_map[hash_].append(widget)
-            else:
-                raise exceptions.InternalError(
-                    "Unexpected on_existing_widget value ({})".format(on_existing_widget))
-
-        return widget
-
-    def delete_widget(self, widget_to_delete, *args, **kwargs):
-        """Delete a widget instance
-
-        this method must be called by frontends when a widget is deleted
-        widget's on_delete method will be called before deletion, and deletion will be
-        stopped if it returns False.
-        @param widget_to_delete(QuickWidget): widget which need to deleted
-        @param *args: extra arguments to pass to on_delete
-        @param *kwargs: extra keywords arguments to pass to on_delete
-            the extra arguments are not used by QuickFrontend, it's is up to
-            the frontend to use them or not.
-            following extra arguments are well known:
-                - "all_instances" can be used as kwarg, if it evaluate to True,
-                  all instances of the widget will be deleted (if on_delete is
-                  not returning False for any of the instance). This arguments
-                  is not sent to on_delete methods.
-                - "explicit_close" is used when the deletion is requested by
-                  the user or a leave signal, "all_instances" is usually set at
-                  the same time.
-        """
-        # TODO: all_instances must be independante kwargs, this is not possible with Python 2
-        #       but will be with Python 3
-        all_instances = kwargs.get('all_instances', False)
-
-        if all_instances:
-            for w in self.get_widget_instances(widget_to_delete):
-                if w.on_delete(**kwargs) == False:
-                    log.debug(
-                        f"Deletion of {widget_to_delete} cancelled by widget itself")
-                    return
-        else:
-            if widget_to_delete.on_delete(**kwargs) == False:
-                log.debug(f"Deletion of {widget_to_delete} cancelled by widget itself")
-                return
-
-        if self.host.selected_widget == widget_to_delete:
-            self.host.selected_widget = None
-
-        class_ = self.get_real_class(widget_to_delete.__class__)
-        try:
-            widgets_map = self._widgets[class_.__name__]
-        except KeyError:
-            log.error("no widgets_map found for class {cls}".format(cls=class_))
-            return
-        widget_hash = str(class_.get_widget_hash(widget_to_delete.target,
-                                                   widget_to_delete.profiles))
-        try:
-            widget_instances = widgets_map[widget_hash]
-        except KeyError:
-            log.error(f"no instance of {class_.__name__} found with hash {widget_hash!r}")
-            return
-        if all_instances:
-            widget_instances.clear()
-        else:
-            try:
-                widget_instances.remove(widget_to_delete)
-            except ValueError:
-                log.error("widget_to_delete not found in widget instances")
-                return
-
-            log.debug("widget {} deleted".format(widget_to_delete))
-
-        if not widget_instances:
-            # all instances with this hash have been deleted
-            # we remove the hash itself
-            del widgets_map[widget_hash]
-            log.debug("All instances of {cls} with hash {widget_hash!r} have been deleted"
-                .format(cls=class_, widget_hash=widget_hash))
-            self.host.call_listeners("widgetDeleted", widget_to_delete)
-
-
-class QuickWidget(object):
-    """generic widget base"""
-    # FIXME: sometime a single target is used, sometimes several ones
-    #        This should be sorted out in the same way as for profiles: a single
-    #        target should be possible when appropriate attribute is set.
-    #        methods using target(s) and hash should be fixed accordingly
-
-    SINGLE = True  # if True, there can be only one widget per target(s)
-    PROFILES_MULTIPLE = False  # If True, this widget can handle several profiles at once
-    PROFILES_ALLOW_NONE = False  # If True, this widget can be used without profile
-
-    def __init__(self, host, target, profiles=None):
-        """
-        @param host: %(doc_host)s
-        @param target: target specific for this widget class
-        @param profiles: can be either:
-            - (unicode): used when widget class manage a unique profile
-            - (iterable): some widget class can manage several profiles, several at once can be specified here
-            - None: no profile is managed by this widget class (rare)
-        @raise: ValueError when (iterable) or None is given to profiles for a widget class which manage one unique profile.
-        """
-        self.host = host
-        self.targets = set()
-        self.add_target(target)
-        self.profiles = set()
-        self._sync = True
-        if isinstance(profiles, str):
-            self.add_profile(profiles)
-        elif profiles is None:
-            if not self.PROFILES_ALLOW_NONE:
-                raise ValueError("profiles can't have a value of None")
-        else:
-            for profile in profiles:
-                self.add_profile(profile)
-            if not self.profiles:
-                raise ValueError("no profile found, use None for no profile classes")
-
-    @property
-    def profile(self):
-        assert (
-            len(self.profiles) == 1
-            and not self.PROFILES_MULTIPLE
-            and not self.PROFILES_ALLOW_NONE
-        )
-        return list(self.profiles)[0]
-
-    @property
-    def target(self):
-        """Return main target
-
-        A random target is returned when several targets are available
-        """
-        return next(iter(self.targets))
-
-    @property
-    def widget_hash(self):
-        """Return quick widget hash"""
-        return self.get_widget_hash(self.target, self.profiles)
-
-    # synchronisation state
-
-    @property
-    def sync(self):
-        return self._sync
-
-    @sync.setter
-    def sync(self, state):
-        """state of synchronisation with backend
-
-        @param state(bool): True when backend is synchronised
-            False is set by core
-            True must be set by the widget when resynchronisation is finished
-        """
-        self._sync = state
-
-    def resync(self):
-        """Method called when backend can be resynchronized
-
-        The widget has to set self.sync itself when the synchronisation is finished
-        """
-        pass
-
-    # target/profile
-
-    def add_target(self, target):
-        """Add a target if it doesn't already exists
-
-        @param target: target to add
-        """
-        self.targets.add(target)
-
-    def add_profile(self, profile):
-        """Add a profile is if doesn't already exists
-
-        @param profile: profile to add
-        """
-        if self.profiles and not self.PROFILES_MULTIPLE:
-            raise ValueError("multiple profiles are not allowed")
-        self.profiles.add(profile)
-
-    # widget identitication
-
-    @staticmethod
-    def get_widget_hash(target, profiles):
-        """Return the hash associated with this target for this widget class
-
-        some widget classes can manage several target on the same instance
-        (e.g.: a chat widget with multiple resources on the same bare jid),
-        this method allow to return a hash associated to one or several targets
-        to retrieve the good instance. For example, a widget managing JID targets,
-        and all resource of the same bare jid would return the bare jid as hash.
-
-        @param target: target to check
-        @param profiles: profile(s) associated to target, see __init__ docstring
-        @return: a hash (can correspond to one or many targets or profiles, depending of widget class)
-        """
-        return str(target)  # by defaut, there is one hash for one target
-
-    # widget life events
-
-    def on_delete(self, *args, **kwargs):
-        """Called when a widget is being deleted
-
-        @return (boot, None): False to cancel deletion
-            all other value continue deletion
-        """
-        return True
-
-    def on_selected(self):
-        """Called when host.selected_widget is this instance"""
-        pass
--- a/sat_frontends/tools/composition.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,132 +0,0 @@
-#!/usr/bin/env python3
-
-
-"""
-Libervia: a Salut à Toi frontend
-Copyright (C) 2013-2016 Adrien Cossa <souliane@mailoo.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/>.
-"""
-
-# Map the messages recipient types to their properties.
-RECIPIENT_TYPES = {
-    "To": {"desc": "Direct recipients", "optional": False},
-    "Cc": {"desc": "Carbon copies", "optional": True},
-    "Bcc": {"desc": "Blind carbon copies", "optional": True},
-}
-
-# Rich text buttons icons and descriptions
-RICH_BUTTONS = {
-    "bold": {"tip": "Bold", "icon": "media/icons/dokuwiki/toolbar/16/bold.png"},
-    "italic": {"tip": "Italic", "icon": "media/icons/dokuwiki/toolbar/16/italic.png"},
-    "underline": {
-        "tip": "Underline",
-        "icon": "media/icons/dokuwiki/toolbar/16/underline.png",
-    },
-    "code": {"tip": "Code", "icon": "media/icons/dokuwiki/toolbar/16/mono.png"},
-    "strikethrough": {
-        "tip": "Strikethrough",
-        "icon": "media/icons/dokuwiki/toolbar/16/strike.png",
-    },
-    "heading": {"tip": "Heading", "icon": "media/icons/dokuwiki/toolbar/16/hequal.png"},
-    "numberedlist": {
-        "tip": "Numbered List",
-        "icon": "media/icons/dokuwiki/toolbar/16/ol.png",
-    },
-    "list": {"tip": "List", "icon": "media/icons/dokuwiki/toolbar/16/ul.png"},
-    "link": {"tip": "Link", "icon": "media/icons/dokuwiki/toolbar/16/linkextern.png"},
-    "horizontalrule": {
-        "tip": "Horizontal rule",
-        "icon": "media/icons/dokuwiki/toolbar/16/hr.png",
-    },
-    "image": {"tip": "Image", "icon": "media/icons/dokuwiki/toolbar/16/image.png"},
-}
-
-# Define here your rich text syntaxes, the key must match the ones used in button.
-# Tupples values must have 3 elements : prefix to the selection or cursor
-# position, sample text to write if the marker is not applied on a selection,
-# suffix to the selection or cursor position.
-# FIXME: must not be hard-coded like this
-RICH_SYNTAXES = {
-    "markdown": {
-        "bold": ("**", "bold", "**"),
-        "italic": ("*", "italic", "*"),
-        "code": ("`", "code", "`"),
-        "heading": ("\n# ", "Heading 1", "\n## Heading 2\n"),
-        "link": ("[desc](", "link", ")"),
-        "list": ("\n* ", "item", "\n    + subitem\n"),
-        "horizontalrule": ("\n***\n", "", ""),
-        "image": ("![desc](", "path", ")"),
-    },
-    "bbcode": {
-        "bold": ("[b]", "bold", "[/b]"),
-        "italic": ("[i]", "italic", "[/i]"),
-        "underline": ("[u]", "underline", "[/u]"),
-        "code": ("[code]", "code", "[/code]"),
-        "strikethrough": ("[s]", "strikethrough", "[/s]"),
-        "link": ("[url=", "link", "]desc[/url]"),
-        "list": ("\n[list] [*]", "item 1", " [*]item 2 [/list]\n"),
-        "image": ('[img alt="desc\]', "path", "[/img]"),
-    },
-    "dokuwiki": {
-        "bold": ("**", "bold", "**"),
-        "italic": ("//", "italic", "//"),
-        "underline": ("__", "underline", "__"),
-        "code": ("<code>", "code", "</code>"),
-        "strikethrough": ("<del>", "strikethrough", "</del>"),
-        "heading": ("\n==== ", "Heading 1", " ====\n=== Heading 2 ===\n"),
-        "link": ("[[", "link", "|desc]]"),
-        "list": ("\n  * ", "item\n", "\n    * subitem\n"),
-        "horizontalrule": ("\n----\n", "", ""),
-        "image": ("{{", "path", " |desc}}"),
-    },
-    "XHTML": {
-        "bold": ("<b>", "bold", "</b>"),
-        "italic": ("<i>", "italic", "</i>"),
-        "underline": ("<u>", "underline", "</u>"),
-        "code": ("<pre>", "code", "</pre>"),
-        "strikethrough": ("<s>", "strikethrough", "</s>"),
-        "heading": ("\n<h3>", "Heading 1", "</h3>\n<h4>Heading 2</h4>\n"),
-        "link": ('<a href="', "link", '">desc</a>'),
-        "list": ("\n<ul><li>", "item 1", "</li><li>item 2</li></ul>\n"),
-        "horizontalrule": ("\n<hr/>\n", "", ""),
-        "image": ('<img src="', "path", '" alt="desc"/>'),
-    },
-}
-
-# Define here the commands that are supported by the WYSIWYG edition.
-# Keys must be the same than the ones used in RICH_SYNTAXES["XHTML"].
-# Values will be used to call execCommand(cmd, False, arg), they can be:
-# - a string used for cmd and arg is assumed empty
-# - a tuple (cmd, prompt, arg) with cmd the name of the command,
-#   prompt the text to display for asking a user input and arg is the
-#   value to use directly without asking the user if prompt is empty.
-COMMANDS = {
-    "bold": "bold",
-    "italic": "italic",
-    "underline": "underline",
-    "code": ("formatBlock", "", "pre"),
-    "strikethrough": "strikeThrough",
-    "heading": ("heading", "Please specify the heading level (h1, h2, h3...)", ""),
-    "link": ("createLink", "Please specify an URL", ""),
-    "list": "insertUnorderedList",
-    "horizontalrule": "insertHorizontalRule",
-    "image": ("insertImage", "Please specify an image path", ""),
-}
-
-# These values should be equal to the ones in plugin_misc_text_syntaxes
-# FIXME: should the plugin import them from here to avoid duplicity? Importing
-# the plugin's values from here is not possible because Libervia would fail.
-PARAM_KEY_COMPOSITION = "Composition"
-PARAM_NAME_SYNTAX = "Syntax"
--- a/sat_frontends/tools/css_color.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,245 +0,0 @@
-#!/usr/bin/env python3
-
-
-# CSS color parsing
-# Copyright (C) 2009-2021 Jérome-Poisson
-
-# 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 libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-CSS_COLORS = {
-    "black": "000000",
-    "silver": "c0c0c0",
-    "gray": "808080",
-    "white": "ffffff",
-    "maroon": "800000",
-    "red": "ff0000",
-    "purple": "800080",
-    "fuchsia": "ff00ff",
-    "green": "008000",
-    "lime": "00ff00",
-    "olive": "808000",
-    "yellow": "ffff00",
-    "navy": "000080",
-    "blue": "0000ff",
-    "teal": "008080",
-    "aqua": "00ffff",
-    "orange": "ffa500",
-    "aliceblue": "f0f8ff",
-    "antiquewhite": "faebd7",
-    "aquamarine": "7fffd4",
-    "azure": "f0ffff",
-    "beige": "f5f5dc",
-    "bisque": "ffe4c4",
-    "blanchedalmond": "ffebcd",
-    "blueviolet": "8a2be2",
-    "brown": "a52a2a",
-    "burlywood": "deb887",
-    "cadetblue": "5f9ea0",
-    "chartreuse": "7fff00",
-    "chocolate": "d2691e",
-    "coral": "ff7f50",
-    "cornflowerblue": "6495ed",
-    "cornsilk": "fff8dc",
-    "crimson": "dc143c",
-    "darkblue": "00008b",
-    "darkcyan": "008b8b",
-    "darkgoldenrod": "b8860b",
-    "darkgray": "a9a9a9",
-    "darkgreen": "006400",
-    "darkgrey": "a9a9a9",
-    "darkkhaki": "bdb76b",
-    "darkmagenta": "8b008b",
-    "darkolivegreen": "556b2f",
-    "darkorange": "ff8c00",
-    "darkorchid": "9932cc",
-    "darkred": "8b0000",
-    "darksalmon": "e9967a",
-    "darkseagreen": "8fbc8f",
-    "darkslateblue": "483d8b",
-    "darkslategray": "2f4f4f",
-    "darkslategrey": "2f4f4f",
-    "darkturquoise": "00ced1",
-    "darkviolet": "9400d3",
-    "deeppink": "ff1493",
-    "deepskyblue": "00bfff",
-    "dimgray": "696969",
-    "dimgrey": "696969",
-    "dodgerblue": "1e90ff",
-    "firebrick": "b22222",
-    "floralwhite": "fffaf0",
-    "forestgreen": "228b22",
-    "gainsboro": "dcdcdc",
-    "ghostwhite": "f8f8ff",
-    "gold": "ffd700",
-    "goldenrod": "daa520",
-    "greenyellow": "adff2f",
-    "grey": "808080",
-    "honeydew": "f0fff0",
-    "hotpink": "ff69b4",
-    "indianred": "cd5c5c",
-    "indigo": "4b0082",
-    "ivory": "fffff0",
-    "khaki": "f0e68c",
-    "lavender": "e6e6fa",
-    "lavenderblush": "fff0f5",
-    "lawngreen": "7cfc00",
-    "lemonchiffon": "fffacd",
-    "lightblue": "add8e6",
-    "lightcoral": "f08080",
-    "lightcyan": "e0ffff",
-    "lightgoldenrodyellow": "fafad2",
-    "lightgray": "d3d3d3",
-    "lightgreen": "90ee90",
-    "lightgrey": "d3d3d3",
-    "lightpink": "ffb6c1",
-    "lightsalmon": "ffa07a",
-    "lightseagreen": "20b2aa",
-    "lightskyblue": "87cefa",
-    "lightslategray": "778899",
-    "lightslategrey": "778899",
-    "lightsteelblue": "b0c4de",
-    "lightyellow": "ffffe0",
-    "limegreen": "32cd32",
-    "linen": "faf0e6",
-    "mediumaquamarine": "66cdaa",
-    "mediumblue": "0000cd",
-    "mediumorchid": "ba55d3",
-    "mediumpurple": "9370db",
-    "mediumseagreen": "3cb371",
-    "mediumslateblue": "7b68ee",
-    "mediumspringgreen": "00fa9a",
-    "mediumturquoise": "48d1cc",
-    "mediumvioletred": "c71585",
-    "midnightblue": "191970",
-    "mintcream": "f5fffa",
-    "mistyrose": "ffe4e1",
-    "moccasin": "ffe4b5",
-    "navajowhite": "ffdead",
-    "oldlace": "fdf5e6",
-    "olivedrab": "6b8e23",
-    "orangered": "ff4500",
-    "orchid": "da70d6",
-    "palegoldenrod": "eee8aa",
-    "palegreen": "98fb98",
-    "paleturquoise": "afeeee",
-    "palevioletred": "db7093",
-    "papayawhip": "ffefd5",
-    "peachpuff": "ffdab9",
-    "peru": "cd853f",
-    "pink": "ffc0cb",
-    "plum": "dda0dd",
-    "powderblue": "b0e0e6",
-    "rosybrown": "bc8f8f",
-    "royalblue": "4169e1",
-    "saddlebrown": "8b4513",
-    "salmon": "fa8072",
-    "sandybrown": "f4a460",
-    "seagreen": "2e8b57",
-    "seashell": "fff5ee",
-    "sienna": "a0522d",
-    "skyblue": "87ceeb",
-    "slateblue": "6a5acd",
-    "slategray": "708090",
-    "slategrey": "708090",
-    "snow": "fffafa",
-    "springgreen": "00ff7f",
-    "steelblue": "4682b4",
-    "tan": "d2b48c",
-    "thistle": "d8bfd8",
-    "tomato": "ff6347",
-    "turquoise": "40e0d0",
-    "violet": "ee82ee",
-    "wheat": "f5deb3",
-    "whitesmoke": "f5f5f5",
-    "yellowgreen": "9acd32",
-    "rebeccapurple": "663399",
-}
-DEFAULT = "000000"
-
-
-def parse(raw_value, as_string=True):
-    """parse CSS color value and return normalised value
-
-    @param raw_value(unicode): CSS value
-    @param as_string(bool): if True return a string,
-        else return a tuple of int
-    @return (unicode, tuple): normalised value
-        if as_string is True, value is 3 or 4 hex words (e.g. u"ff00aabb")
-        else value is a 3 or 4 tuple of int (e.g.: (255, 0, 170, 187)).
-        If present, the 4th value is the alpha channel
-        If value can't be parsed, a warning message is logged, and DEFAULT is returned
-    """
-    raw_value = raw_value.strip().lower()
-    if raw_value.startswith("#"):
-        # we have a hexadecimal value
-        str_value = raw_value[1:]
-        if len(raw_value) in (3, 4):
-            str_value = "".join([2 * v for v in str_value])
-    elif raw_value.startswith("rgb"):
-        left_p = raw_value.find("(")
-        right_p = raw_value.find(")")
-        rgb_values = [v.strip() for v in raw_value[left_p + 1 : right_p].split(",")]
-        expected_len = 4 if raw_value.startswith("rgba") else 3
-        if len(rgb_values) != expected_len:
-            log.warning("incorrect value: {}".format(raw_value))
-            str_value = DEFAULT
-        else:
-            int_values = []
-            for rgb_v in rgb_values:
-                p_idx = rgb_v.find("%")
-                if p_idx == -1:
-                    # base 10 value
-                    try:
-                        int_v = int(rgb_v)
-                        if int_v > 255:
-                            raise ValueError("value exceed 255")
-                        int_values.append(int_v)
-                    except ValueError:
-                        log.warning("invalid int: {}".format(rgb_v))
-                        int_values.append(0)
-                else:
-                    # percentage
-                    try:
-                        int_v = int(int(rgb_v[:p_idx]) / 100.0 * 255)
-                        if int_v > 255:
-                            raise ValueError("value exceed 255")
-                        int_values.append(int_v)
-                    except ValueError:
-                        log.warning("invalid percent value: {}".format(rgb_v))
-                        int_values.append(0)
-            str_value = "".join(["{:02x}".format(v) for v in int_values])
-    elif raw_value.startswith("hsl"):
-        log.warning("hue-saturation-lightness not handled yet")  # TODO
-        str_value = DEFAULT
-    else:
-        try:
-            str_value = CSS_COLORS[raw_value]
-        except KeyError:
-            log.warning("unrecognised format: {}".format(raw_value))
-            str_value = DEFAULT
-
-    if as_string:
-        return str_value
-    else:
-        return tuple(
-            [
-                int(str_value[i] + str_value[i + 1], 16)
-                for i in range(0, len(str_value), 2)
-            ]
-        )
--- a/sat_frontends/tools/games.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,85 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 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/>.
-
-"""This library help manage general games (e.g. card games) and it is shared by the frontends"""
-
-SUITS_ORDER = [
-    "pique",
-    "coeur",
-    "trefle",
-    "carreau",
-    "atout",
-]  # I have switched the usual order 'trefle' and 'carreau' because card are more easy to see if suit colour change (black, red, black, red)
-VALUES_ORDER = [str(i) for i in range(1, 11)] + ["valet", "cavalier", "dame", "roi"]
-
-
-class TarotCard(object):
-    """This class is used to represent a car logically"""
-
-    def __init__(self, tuple_card):
-        """@param tuple_card: tuple (suit, value)"""
-        self.suit, self.value = tuple_card
-        self.bout = self.suit == "atout" and self.value in ["1", "21", "excuse"]
-        if self.bout or self.value == "roi":
-            self.points = 4.5
-        elif self.value == "dame":
-            self.points = 3.5
-        elif self.value == "cavalier":
-            self.points = 2.5
-        elif self.value == "valet":
-            self.points = 1.5
-        else:
-            self.points = 0.5
-
-    def get_tuple(self):
-        return (self.suit, self.value)
-
-    @staticmethod
-    def from_tuples(tuple_list):
-        result = []
-        for card_tuple in tuple_list:
-            result.append(TarotCard(card_tuple))
-        return result
-
-    def __cmp__(self, other):
-        if other is None:
-            return 1
-        if self.suit != other.suit:
-            idx1 = SUITS_ORDER.index(self.suit)
-            idx2 = SUITS_ORDER.index(other.suit)
-            return idx1.__cmp__(idx2)
-        if self.suit == "atout":
-            if self.value == other.value == "excuse":
-                return 0
-            if self.value == "excuse":
-                return -1
-            if other.value == "excuse":
-                return 1
-            return int(self.value).__cmp__(int(other.value))
-        # at this point we have the same suit which is not 'atout'
-        idx1 = VALUES_ORDER.index(self.value)
-        idx2 = VALUES_ORDER.index(other.value)
-        return idx1.__cmp__(idx2)
-
-    def __str__(self):
-        return "[%s,%s]" % (self.suit, self.value)
-
-
-# These symbols are diplayed by Libervia next to the player's nicknames
-SYMBOLS = {"Radiocol": ["♬"], "Tarot": ["♠", "♣", "♥", "♦"]}
--- a/sat_frontends/tools/host_listener.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,47 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 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/>.
-
-"""This module is only used launch callbacks when host is ready, used for early initialisation stuffs"""
-
-
-listeners = []
-
-
-def addListener(cb):
-    """Add a listener which will be called when host is ready
-
-    @param cb: callback which will be called when host is ready with host as only argument
-    """
-    listeners.append(cb)
-
-
-def call_listeners(host):
-    """Must be called by frontend when host is ready.
-
-    The call will launch all the callbacks, then remove the listeners list.
-    @param host(QuickApp): the instancied QuickApp subclass
-    """
-    global listeners
-    while True:
-        try:
-            cb = listeners.pop(0)
-            cb(host)
-        except IndexError:
-            break
-    del listeners
--- a/sat_frontends/tools/jid.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia XMPP
-# Copyright (C) 2009-2023 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 typing import Optional, Tuple
-
-
-class JID(str):
-    """This class helps manage JID (<local>@<domain>/<resource>)"""
-
-    def __new__(cls, jid_str: str) -> "JID":
-        return str.__new__(cls, cls._normalize(jid_str))
-
-    def __init__(self, jid_str: str):
-        self._node, self._domain, self._resource = self._parse()
-
-    @staticmethod
-    def _normalize(jid_str: str) -> str:
-        """Naive normalization before instantiating and parsing the JID"""
-        if not jid_str:
-            return jid_str
-        tokens = jid_str.split("/")
-        tokens[0] = tokens[0].lower()  # force node and domain to lower-case
-        return "/".join(tokens)
-
-    def _parse(self) -> Tuple[Optional[str], str, Optional[str]]:
-        """Find node, domain, and resource from JID"""
-        node_end = self.find("@")
-        if node_end < 0:
-            node_end = 0
-        domain_end = self.find("/")
-        if domain_end == 0:
-            raise ValueError("a jid can't start with '/'")
-        if domain_end == -1:
-            domain_end = len(self)
-        node = self[:node_end] or None
-        domain = self[(node_end + 1) if node_end else 0 : domain_end]
-        resource = self[domain_end + 1 :] or None
-        return node, domain, resource
-
-    @property
-    def node(self) -> Optional[str]:
-        return self._node
-
-    @property
-    def local(self) -> Optional[str]:
-        return self._node
-
-    @property
-    def domain(self) -> str:
-        return self._domain
-
-    @property
-    def resource(self) -> Optional[str]:
-        return self._resource
-
-    @property
-    def bare(self) -> "JID":
-        if not self.node:
-            return JID(self.domain)
-        return JID(f"{self.node}@{self.domain}")
-
-    def change_resource(self, resource: str) -> "JID":
-        """Build a new JID with the same node and domain but a different resource.
-
-        @param resource: The new resource for the JID.
-        @return: A new JID instance with the updated resource.
-        """
-        return JID(f"{self.bare}/{resource}")
-
-    def is_valid(self) -> bool:
-        """
-        @return: True if the JID is XMPP compliant
-        """
-        # Simple check for domain part
-        if not self.domain or self.domain.startswith(".") or self.domain.endswith("."):
-            return False
-        if ".." in self.domain:
-            return False
-        return True
-
-
-def new_resource(entity: JID, resource: str) -> JID:
-    """Build a new JID from the given entity and resource.
-
-    @param entity: original JID
-    @param resource: new resource
-    @return: a new JID instance
-    """
-    return entity.change_resource(resource)
--- a/sat_frontends/tools/misc.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,96 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT helpers methods for plugins
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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/>.
-
-
-class InputHistory(object):
-    def _update_input_history(self, text=None, step=None, callback=None, mode=""):
-        """Update the lists of previously sent messages. Several lists can be
-        handled as they are stored in a dictionary, the argument "mode" being
-        used as the entry key. There's also a temporary list to allow you play
-        with previous entries before sending a new message. Parameters values
-        can be combined: text is None and step is None to initialize a main
-        list and the temporary one, step is None to update a list and
-        reinitialize the temporary one, step is not None to update
-        the temporary list between two messages.
-        @param text: text to be saved.
-        @param step: step to move the temporary index.
-        @param callback: method to display temporary entries.
-        @param mode: the dictionary key for main lists.
-        """
-        if not hasattr(self, "input_histories"):
-            self.input_histories = {}
-        history = self.input_histories.setdefault(mode, [])
-        if step is None and text is not None:
-            # update the main list
-            if text in history:
-                history.remove(text)
-            history.append(text)
-        length = len(history)
-        if step is None or length == 0:
-            # prepare the temporary list and index
-            self.input_history_tmp = history[:]
-            self.input_history_tmp.append("")
-            self.input_history_index = length
-        if step is None:
-            return
-        # update the temporary list
-        if text is not None:
-            # save the current entry
-            self.input_history_tmp[self.input_history_index] = text
-        # move to another entry if possible
-        index_tmp = self.input_history_index + step
-        if index_tmp >= 0 and index_tmp < len(self.input_history_tmp):
-            if callback is not None:
-                callback(self.input_history_tmp[index_tmp])
-            self.input_history_index = index_tmp
-
-
-class FlagsHandler(object):
-    """Small class to handle easily option flags
-
-    the instance is initialized with an iterable
-    then attribute return True if flag is set, False else.
-    """
-
-    def __init__(self, flags):
-        self.flags = set(flags or [])
-        self._used_flags = set()
-
-    def __getattr__(self, flag):
-        self._used_flags.add(flag)
-        return flag in self.flags
-
-    def __getitem__(self, flag):
-        return getattr(self, flag)
-
-    def __len__(self):
-        return len(self.flags)
-
-    def __iter__(self):
-        return self.flags.__iter__()
-
-    @property
-    def all_used(self):
-        """Return True if all flags have been used"""
-        return self._used_flags.issuperset(self.flags)
-
-    @property
-    def unused(self):
-        """Return flags which has not been used yet"""
-        return self.flags.difference(self._used_flags)
--- a/sat_frontends/tools/strings.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,102 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT helpers methods for plugins
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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/>.
-
-import re
-
-# Regexp from http://daringfireball.net/2010/07/improved_regex_for_matching_urls
-RE_URL = re.compile(
-    r"""(?i)\b((?:[a-z]{3,}://|(www|ftp)\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/|mailto:|xmpp:|geo:)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?]))"""
-)
-
-
-# TODO: merge this class with an other module or at least rename it (strings is not a good name)
-
-
-def get_url_params(url):
-    """This comes from pyjamas.Location.makeUrlDict with a small change
-    to also parse full URLs, and parameters with no value specified
-    (in that case the default value "" is used).
-    @param url: any URL with or without parameters
-    @return: a dictionary of the parameters, if any was given, or {}
-    """
-    dict_ = {}
-    if "/" in url:
-        # keep the part after the last "/"
-        url = url[url.rindex("/") + 1 :]
-    if url.startswith("?"):
-        # remove the first "?"
-        url = url[1:]
-    pairs = url.split("&")
-    for pair in pairs:
-        if len(pair) < 3:
-            continue
-        kv = pair.split("=", 1)
-        dict_[kv[0]] = kv[1] if len(kv) > 1 else ""
-    return dict_
-
-
-def add_url_to_text(string, new_target=True):
-    """Check a text for what looks like an URL and make it clickable.
-
-    @param string (unicode): text to process
-    @param new_target (bool): if True, make the link open in a new window
-    """
-    # XXX: report any change to libervia.browser.strings.add_url_to_text
-    def repl(match):
-        url = match.group(0)
-        if not re.match(r"""[a-z]{3,}://|mailto:|xmpp:""", url):
-            url = "http://" + url
-        target = ' target="_blank"' if new_target else ""
-        return '<a href="%s"%s class="url">%s</a>' % (url, target, match.group(0))
-
-    return RE_URL.sub(repl, string)
-
-
-def add_url_to_image(string):
-    """Check a XHTML text for what looks like an imageURL and make it clickable.
-
-    @param string (unicode): text to process
-    """
-    # XXX: report any change to libervia.browser.strings.add_url_to_image
-    def repl(match):
-        url = match.group(1)
-        return '<a href="%s" target="_blank">%s</a>' % (url, match.group(0))
-
-    pattern = r"""<img[^>]* src="([^"]+)"[^>]*>"""
-    return re.sub(pattern, repl, string)
-
-
-def fix_xhtml_links(xhtml):
-    """Add http:// if the scheme is missing and force opening in a new window.
-
-    @param string (unicode): XHTML Content
-    """
-    subs = []
-    for match in re.finditer(r'<a( \w+="[^"]*")* ?/?>', xhtml):
-        tag = match.group(0)
-        url = re.search(r'href="([^"]*)"', tag)
-        if url and not url.group(1).startswith("#"):  # skip internal anchor
-            if not re.search(r'target="([^"]*)"', tag):  # no target
-                subs.append((tag, '<a target="_blank"%s' % tag[2:]))
-            if not re.match(r"^\w+://", url.group(1)):  # no scheme
-                subs.append((url.group(0), 'href="http://%s"' % url.group(1)))
-
-    for url, new_url in subs:
-        xhtml = xhtml.replace(url, new_url)
-    return xhtml
--- a/sat_frontends/tools/xmltools.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,47 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 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/>.
-
-"""This library help manage XML used in SàT frontends """
-
-# we don't import minidom as a different class can be used in frontends
-# (e.g. NativeDOM in Libervia)
-
-
-def inline_root(doc):
-    """ make the root attribute inline
-    @param root_node: minidom's Document compatible class
-    @return: plain XML
-    """
-    root_elt = doc.documentElement
-    if root_elt.hasAttribute("style"):
-        styles_raw = root_elt.getAttribute("style")
-        styles = styles_raw.split(";")
-        new_styles = []
-        for style in styles:
-            try:
-                key, value = style.split(":")
-            except ValueError:
-                continue
-            if key.strip().lower() == "display":
-                value = "inline"
-            new_styles.append("%s: %s" % (key.strip(), value.strip()))
-        root_elt.setAttribute("style", "; ".join(new_styles))
-    else:
-        root_elt.setAttribute("style", "display: inline")
-    return root_elt.toxml()
--- a/sat_frontends/tools/xmlui.py	Fri Jun 02 12:59:21 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1149 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT frontend tools
-# Copyright (C) 2009-2021 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 libervia.backend.core.i18n import _
-from libervia.backend.core.log import getLogger
-
-log = getLogger(__name__)
-from sat_frontends.quick_frontend.constants import Const as C
-from libervia.backend.core import exceptions
-
-
-_class_map = {}
-CLASS_PANEL = "panel"
-CLASS_DIALOG = "dialog"
-CURRENT_LABEL = "current_label"
-HIDDEN = "hidden"
-
-
-class InvalidXMLUI(Exception):
-    pass
-
-
-class ClassNotRegistedError(Exception):
-    pass
-
-
-# FIXME: this method is duplicated in frontends.tools.xmlui.get_text
-def get_text(node):
-    """Get child text nodes
-    @param node: dom Node
-    @return: joined unicode text of all nodes
-
-    """
-    data = []
-    for child in node.childNodes:
-        if child.nodeType == child.TEXT_NODE:
-            data.append(child.wholeText)
-    return "".join(data)
-
-
-class Widget(object):
-    """base Widget"""
-
-    pass
-
-
-class EmptyWidget(Widget):
-    """Just a placeholder widget"""
-
-    pass
-
-
-class TextWidget(Widget):
-    """Non interactive text"""
-
-    pass
-
-
-class LabelWidget(Widget):
-    """Non interactive text"""
-
-    pass
-
-
-class JidWidget(Widget):
-    """Jabber ID"""
-
-    pass
-
-
-class DividerWidget(Widget):
-    """Separator"""
-
-    pass
-
-
-class StringWidget(Widget):
-    """Input widget wich require a string
-
-    often called Edit in toolkits
-    """
-
-    pass
-
-
-class JidInputWidget(Widget):
-    """Input widget wich require a string
-
-    often called Edit in toolkits
-    """
-
-    pass
-
-
-class PasswordWidget(Widget):
-    """Input widget with require a masked string"""
-
-    pass
-
-
-class TextBoxWidget(Widget):
-    """Input widget with require a long, possibly multilines string
-
-    often called TextArea in toolkits
-    """
-
-    pass
-
-
-class XHTMLBoxWidget(Widget):
-    """Input widget specialised in XHTML editing,
-
-    a WYSIWYG or specialised editor is expected
-    """
-
-    pass
-
-
-class BoolWidget(Widget):
-    """Input widget with require a boolean value
-    often called CheckBox in toolkits
-    """
-
-    pass
-
-
-class IntWidget(Widget):
-    """Input widget with require an integer"""
-
-    pass
-
-
-class ButtonWidget(Widget):
-    """A clickable widget"""
-
-    pass
-
-
-class ListWidget(Widget):
-    """A widget able to show/choose one or several strings in a list"""
-
-    pass
-
-
-class JidsListWidget(Widget):
-    """A widget able to show/choose one or several strings in a list"""
-
-    pass
-
-
-class Container(Widget):
-    """Widget which can contain other ones with a specific layout"""
-
-    @classmethod
-    def _xmlui_adapt(cls, instance):
-        """Make cls as instance.__class__
-
-        cls must inherit from original instance class
-        Usefull when you get a class from UI toolkit
-        """
-        assert instance.__class__ in cls.__bases__
-        instance.__class__ = type(cls.__name__, cls.__bases__, dict(cls.__dict__))
-
-
-class PairsContainer(Container):
-    """Widgets are disposed in rows of two (usually label/input)"""
-
-    pass
-
-
-class LabelContainer(Container):
-    """Widgets are associated with label or empty widget"""
-
-    pass
-
-
-class TabsContainer(Container):
-    """A container which several other containers in tabs
-
-    Often called Notebook in toolkits
-    """
-
-    pass
-
-
-class VerticalContainer(Container):
-    """Widgets are disposed vertically"""
-
-    pass
-
-
-class AdvancedListContainer(Container):
-    """Widgets are disposed in rows with advaned features"""
-
-    pass
-
-
-class Dialog(object):
-    """base dialog"""
-
-    def __init__(self, _xmlui_parent):
-        self._xmlui_parent = _xmlui_parent
-
-    def _xmlui_validated(self, data=None):
-        if data is None:
-            data = {}
-        self._xmlui_set_data(C.XMLUI_STATUS_VALIDATED, data)
-        self._xmlui_submit(data)
-
-    def _xmlui_cancelled(self):
-        data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE}
-        self._xmlui_set_data(C.XMLUI_STATUS_CANCELLED, data)
-        self._xmlui_submit(data)
-
-    def _xmlui_submit(self, data):
-        if self._xmlui_parent.submit_id is None:
-            log.debug(_("Nothing to submit"))
-        else:
-            self._xmlui_parent.submit(data)
-
-    def _xmlui_set_data(self, status, data):
-        pass
-
-
-class MessageDialog(Dialog):
-    """Dialog with a OK/Cancel type configuration"""
-
-    pass
-
-
-class NoteDialog(Dialog):
-    """Short message which doesn't need user confirmation to disappear"""
-
-    pass
-
-
-class ConfirmDialog(Dialog):
-    """Dialog with a OK/Cancel type configuration"""
-
-    def _xmlui_set_data(self, status, data):
-        if status == C.XMLUI_STATUS_VALIDATED:
-            data[C.XMLUI_DATA_ANSWER] = C.BOOL_TRUE
-        elif status == C.XMLUI_STATUS_CANCELLED:
-            data[C.XMLUI_DATA_ANSWER] = C.BOOL_FALSE
-
-
-class FileDialog(Dialog):
-    """Dialog with a OK/Cancel type configuration"""
-
-    pass
-
-
-class XMLUIBase(object):
-    """Base class to construct SàT XML User Interface
-
-    This class must not be instancied directly
-    """
-
-    def __init__(self, host, parsed_dom, title=None, flags=None, callback=None,
-                 profile=C.PROF_KEY_NONE):
-        """Initialise the XMLUI instance
-
-        @param host: %(doc_host)s
-        @param parsed_dom: main parsed dom
-        @param title: force the title, or use XMLUI one if None
-        @param flags: list of string which can be:
-            - NO_CANCEL: the UI can't be cancelled
-            - FROM_BACKEND: the UI come from backend (i.e. it's not the direct result of
-                            user operation)
-        @param callback(callable, None): if not None, will be used with action_launch:
-            - if None is used, default behaviour will be used (closing the dialog and
-              calling host.action_manager)
-            - if a callback is provided, it will be used instead, so you'll have to manage
-                dialog closing or new xmlui to display, or other action (you can call
-                host.action_manager)
-                The callback will have data, callback_id and profile as arguments
-        """
-        self.host = host
-        top = parsed_dom.documentElement
-        self.session_id = top.getAttribute("session_id") or None
-        self.submit_id = top.getAttribute("submit") or None
-        self.xmlui_title = title or top.getAttribute("title") or ""
-        self.hidden = {}
-        if flags is None:
-            flags = []
-        self.flags = flags
-        self.callback = callback or self._default_cb
-        self.profile = profile
-
-    @property
-    def user_action(self):
-        return "FROM_BACKEND" not in self.flags
-
-    def _default_cb(self, data, cb_id, profile):
-        # TODO: when XMLUI updates will be managed, the _xmlui_close
-        #       must be called only if there is no update
-        self._xmlui_close()
-        self.host.action_manager(data, profile=profile)
-
-    def _is_attr_set(self, name, node):
-        """Return widget boolean attribute status
-
-        @param name: name of the attribute (e.g. "read_only")
-        @param node: Node instance
-        @return (bool): True if widget's attribute is set (C.BOOL_TRUE)
-        """
-        read_only = node.getAttribute(name) or C.BOOL_FALSE
-        return read_only.lower().strip() == C.BOOL_TRUE
-
-    def _get_child_node(self, node, name):
-        """Return the first child node with the given name
-
-        @param node: Node instance
-        @param name: name of the wanted node
-
-        @return: The found element or None
-        """
-        for child in node.childNodes:
-            if child.nodeName == name:
-                return child
-        return None
-
-    def submit(self, data):
-        self._xmlui_close()
-        if self.submit_id is None:
-            raise ValueError("Can't submit is self.submit_id is not set")
-        if "session_id" in data:
-            raise ValueError(
-                "session_id must no be used in data, it is automaticaly filled with "
-                "self.session_id if present"
-            )
-        if self.session_id is not None:
-            data["session_id"] = self.session_id
-        self._xmlui_launch_action(self.submit_id, data)
-
-    def _xmlui_launch_action(self, action_id, data):
-        self.host.action_launch(
-            action_id, data, callback=self.callback, profile=self.profile
-        )
-
-    def _xmlui_close(self):
-        """Close the window/popup/... where the constructor XMLUI is
-
-        this method must be overrided
-        """
-        raise NotImplementedError
-
-
-class ValueGetter(object):
-    """dict like object which return values of widgets"""
-    # FIXME: widget which can keep multiple values are not handled
-
-    def __init__(self, widgets, attr="value"):
-        self.attr = attr
-        self.widgets = widgets
-
-    def __getitem__(self, name):
-        return getattr(self.widgets[name], self.attr)
-
-    def __getattr__(self, name):
-        return self.__getitem__(name)
-
-    def keys(self):
-        return list(self.widgets.keys())
-
-    def items(self):
-        for name, widget in self.widgets.items():
-            try:
-                value = widget.value
-            except AttributeError:
-                try:
-                    value = list(widget.values)
-                except AttributeError:
-                    continue
-            yield name, value
-
-
-class XMLUIPanel(XMLUIBase):
-    """XMLUI Panel
-
-    New frontends can inherit this class to easily implement XMLUI
-    @property widget_factory: factory to create frontend-specific widgets
-    @property dialog_factory: factory to create frontend-specific dialogs
-    """
-
-    widget_factory = None
-
-    def __init__(self, host, parsed_dom, title=None, flags=None, callback=None,
-                 ignore=None, whitelist=None, profile=C.PROF_KEY_NONE):
-        """
-
-        @param title(unicode, None): title of the
-        @property widgets(dict): widget name => widget map
-        @property widget_value(ValueGetter): retrieve widget value from it's name
-        """
-        super(XMLUIPanel, self).__init__(
-            host, parsed_dom, title=title, flags=flags, callback=callback, profile=profile
-        )
-        self.ctrl_list = {}  # input widget, used mainly for forms
-        self.widgets = {}  #  allow to access any named widgets
-        self.widget_value = ValueGetter(self.widgets)
-        self._main_cont = None
-        if ignore is None:
-            ignore = []
-        self._ignore = ignore
-        if whitelist is not None:
-            if ignore:
-                raise exceptions.InternalError(
-                    "ignore and whitelist must not be used at the same time"
-                )
-            self._whitelist = whitelist
-        else:
-            self._whitelist = None
-        self.construct_ui(parsed_dom)
-
-    @staticmethod
-    def escape(name):
-        """Return escaped name for forms"""
-        return "%s%s" % (C.SAT_FORM_PREFIX, name)
-
-    @property
-    def main_cont(self):
-        return self._main_cont
-
-    @property
-    def values(self):
-        """Dict of all widgets values"""
-        return dict(self.widget_value.items())
-
-    @main_cont.setter
-    def main_cont(self, value):
-        if self._main_cont is not None:
-            raise ValueError(_("XMLUI can have only one main container"))
-        self._main_cont = value
-
-    def _parse_childs(self, _xmlui_parent, current_node, wanted=("container",), data=None):
-        """Recursively parse childNodes of an element
-
-        @param _xmlui_parent: widget container with '_xmlui_append' method
-        @param current_node: element from which childs will be parsed
-        @param wanted: list of tag names that can be present in the childs to be SàT XMLUI
-                       compliant
-        @param data(None, dict): additionnal data which are needed in some cases
-        """
-        for node in current_node.childNodes:
-            if data is None:
-                data = {}
-            if wanted and not node.nodeName in wanted:
-                raise InvalidXMLUI("Unexpected node: [%s]" % node.nodeName)
-
-            if node.nodeName == "container":
-                type_ = node.getAttribute("type")
-                if _xmlui_parent is self and type_ not in ("vertical", "tabs"):
-                    # main container is not a VerticalContainer and we want one,
-                    # so we create one to wrap it
-                    _xmlui_parent = self.widget_factory.createVerticalContainer(self)
-                    self.main_cont = _xmlui_parent
-                if type_ == "tabs":
-                    cont = self.widget_factory.createTabsContainer(_xmlui_parent)
-                    self._parse_childs(_xmlui_parent, node, ("tab",), {"tabs_cont": cont})
-                elif type_ == "vertical":
-                    cont = self.widget_factory.createVerticalContainer(_xmlui_parent)
-                    self._parse_childs(cont, node, ("widget", "container"))
-                elif type_ == "pairs":
-                    cont = self.widget_factory.createPairsContainer(_xmlui_parent)
-                    self._parse_childs(cont, node, ("widget", "container"))
-                elif type_ == "label":
-                    cont = self.widget_factory.createLabelContainer(_xmlui_parent)
-                    self._parse_childs(
-                        # FIXME: the "None" value for CURRENT_LABEL doesn't seem
-                        #        used or even useful, it should probably be removed
-                        #        and all "is not None" tests for it should be removed too
-                        #        to be checked for 0.8
-                        cont, node, ("widget", "container"), {CURRENT_LABEL: None}
-                    )
-                elif type_ == "advanced_list":
-                    try:
-                        columns = int(node.getAttribute("columns"))
-                    except (TypeError, ValueError):
-                        raise exceptions.DataError("Invalid columns")
-                    selectable = node.getAttribute("selectable") or "no"
-                    auto_index = node.getAttribute("auto_index") == C.BOOL_TRUE
-                    data = {"index": 0} if auto_index else None
-                    cont = self.widget_factory.createAdvancedListContainer(
-                        _xmlui_parent, columns, selectable
-                    )
-                    callback_id = node.getAttribute("callback") or None
-                    if callback_id is not None:
-                        if selectable == "no":
-                            raise ValueError(
-                                "can't have selectable=='no' and callback_id at the same time"
-                            )
-                        cont._xmlui_callback_id = callback_id
-                        cont._xmlui_on_select(self.on_adv_list_select)
-
-                    self._parse_childs(cont, node, ("row",), data)
-                else:
-                    log.warning(_("Unknown container [%s], using default one") % type_)
-                    cont = self.widget_factory.createVerticalContainer(_xmlui_parent)
-                    self._parse_childs(cont, node, ("widget", "container"))
-                try:
-                    xmluiAppend = _xmlui_parent._xmlui_append
-                except (
-                    AttributeError,
-                    TypeError,
-                ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
-                    if _xmlui_parent is self:
-                        self.main_cont = cont
-                    else:
-                        raise Exception(
-                            _("Internal Error, container has not _xmlui_append method")
-                        )
-                else:
-                    xmluiAppend(cont)
-
-            elif node.nodeName == "tab":
-                name = node.getAttribute("name")
-                label = node.getAttribute("label")
-                selected = C.bool(node.getAttribute("selected") or C.BOOL_FALSE)
-                if not name or not "tabs_cont" in data:
-                    raise InvalidXMLUI
-                if self.type == "param":
-                    self._current_category = (
-                        name
-                    )  # XXX: awful hack because params need category and we don't keep parent
-                tab_cont = data["tabs_cont"]
-                new_tab = tab_cont._xmlui_add_tab(label or name, selected)
-                self._parse_childs(new_tab, node, ("widget", "container"))
-
-            elif node.nodeName == "row":
-                try:
-                    index = str(data["index"])
-                except KeyError:
-                    index = node.getAttribute("index") or None
-                else:
-                    data["index"] += 1
-                _xmlui_parent._xmlui_add_row(index)
-                self._parse_childs(_xmlui_parent, node, ("widget", "container"))
-
-            elif node.nodeName == "widget":
-                name = node.getAttribute("name")
-                if name and (
-                    name in self._ignore
-                    or self._whitelist is not None
-                    and name not in self._whitelist
-                ):
-                    # current widget is ignored, but there may be already a label
-                    if CURRENT_LABEL in data:
-                        curr_label = data.pop(CURRENT_LABEL)
-                        if curr_label is not None:
-                            # if so, we remove it from parent
-                            _xmlui_parent._xmlui_remove(curr_label)
-                    continue
-                type_ = node.getAttribute("type")
-                value_elt = self._get_child_node(node, "value")
-                if value_elt is not None:
-                    value = get_text(value_elt)
-                else:
-                    value = (
-                        node.getAttribute("value") if node.hasAttribute("value") else ""
-                    )
-                if type_ == "empty":
-                    ctrl = self.widget_factory.createEmptyWidget(_xmlui_parent)
-                    if CURRENT_LABEL in data:
-                        data[CURRENT_LABEL] = None
-                elif type_ == "text":
-                    ctrl = self.widget_factory.createTextWidget(_xmlui_parent, value)
-                elif type_ == "label":
-                    ctrl = self.widget_factory.createLabelWidget(_xmlui_parent, value)
-                    data[CURRENT_LABEL] = ctrl
-                elif type_ == "hidden":
-                    if name in self.hidden:
-                        raise exceptions.ConflictError("Conflict on hidden value with "
-                                                       "name {name}".format(name=name))
-                    self.hidden[name] = value
-                    continue
-                elif type_ == "jid":
-                    ctrl = self.widget_factory.createJidWidget(_xmlui_parent, value)
-                elif type_ == "divider":
-                    style = node.getAttribute("style") or "line"
-                    ctrl = self.widget_factory.createDividerWidget(_xmlui_parent, style)
-                elif type_ == "string":
-                    ctrl = self.widget_factory.createStringWidget(
-                        _xmlui_parent, value, self._is_attr_set("read_only", node)
-                    )
-                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
-                elif type_ == "jid_input":
-                    ctrl = self.widget_factory.createJidInputWidget(
-                        _xmlui_parent, value, self._is_attr_set("read_only", node)
-                    )
-                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
-                elif type_ == "password":
-                    ctrl = self.widget_factory.createPasswordWidget(
-                        _xmlui_parent, value, self._is_attr_set("read_only", node)
-                    )
-                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
-                elif type_ == "textbox":
-                    ctrl = self.widget_factory.createTextBoxWidget(
-                        _xmlui_parent, value, self._is_attr_set("read_only", node)
-                    )
-                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
-                elif type_ == "xhtmlbox":
-                    ctrl = self.widget_factory.createXHTMLBoxWidget(
-                        _xmlui_parent, value, self._is_attr_set("read_only", node)
-                    )
-                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
-                elif type_ == "bool":
-                    ctrl = self.widget_factory.createBoolWidget(
-                        _xmlui_parent,
-                        value == C.BOOL_TRUE,
-                        self._is_attr_set("read_only", node),
-                    )
-                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
-                elif type_ == "int":
-                    ctrl = self.widget_factory.createIntWidget(
-                        _xmlui_parent, value, self._is_attr_set("read_only", node)
-                    )
-                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
-                elif type_ == "list":
-                    style = [] if node.getAttribute("multi") == "yes" else ["single"]
-                    for attr in ("noselect", "extensible", "reducible", "inline"):
-                        if node.getAttribute(attr) == "yes":
-                            style.append(attr)
-                    _options = [
-                        (option.getAttribute("value"), option.getAttribute("label"))
-                        for option in node.getElementsByTagName("option")
-                    ]
-                    _selected = [
-                        option.getAttribute("value")
-                        for option in node.getElementsByTagName("option")
-                        if option.getAttribute("selected") == C.BOOL_TRUE
-                    ]
-                    ctrl = self.widget_factory.createListWidget(
-                        _xmlui_parent, _options, _selected, style
-                    )
-                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
-                elif type_ == "jids_list":
-                    style = []
-                    jids = [get_text(jid_) for jid_ in node.getElementsByTagName("jid")]
-                    ctrl = self.widget_factory.createJidsListWidget(
-                        _xmlui_parent, jids, style
-                    )
-                    self.ctrl_list[name] = {"type": type_, "control": ctrl}
-                elif type_ == "button":
-                    callback_id = node.getAttribute("callback")
-                    ctrl = self.widget_factory.createButtonWidget(
-                        _xmlui_parent, value, self.on_button_press
-                    )
-                    ctrl._xmlui_param_id = (
-                        callback_id,
-                        [
-                            field.getAttribute("name")
-                            for field in node.getElementsByTagName("field_back")
-                        ],
-                    )
-                else:
-                    log.error(
-                        _("FIXME FIXME FIXME: widget type [%s] is not implemented")
-                        % type_
-                    )
-                    raise NotImplementedError(
-                        _("FIXME FIXME FIXME: type [%s] is not implemented") % type_
-                    )
-
-                if name:
-                    self.widgets[name] = ctrl
-
-                if self.type == "param" and type_ not in ("text", "button"):
-                    try:
-                        ctrl._xmlui_on_change(self.on_param_change)
-                        ctrl._param_category = self._current_category
-                    except (
-                        AttributeError,
-                        TypeError,
-                    ):  # XXX: TypeError is here because pyjamas raise a TypeError instead
-                        #      of an AttributeError
-                        if not isinstance(
-                            ctrl, (EmptyWidget, TextWidget, LabelWidget, JidWidget)
-                        ):
-                            log.warning(_("No change listener on [%s]") % ctrl)
-
-                elif type_ != "text":
-                    callback = node.getAttribute("internal_callback") or None
-                    if callback:
-                        fields = [
-                            field.getAttribute("name")
-                            for field in node.getElementsByTagName("internal_field")
-                        ]
-                        cb_data = self.get_internal_callback_data(callback, node)
-                        ctrl._xmlui_param_internal = (callback, fields, cb_data)
-                        if type_ == "button":
-                            ctrl._xmlui_on_click(self.on_change_internal)
-                        else:
-                            ctrl._xmlui_on_change(self.on_change_internal)
-
-                ctrl._xmlui_name = name
-                _xmlui_parent._xmlui_append(ctrl)
-                if CURRENT_LABEL in data and not isinstance(ctrl, LabelWidget):
-                    curr_label = data.pop(CURRENT_LABEL)
-                    if curr_label is not None:
-                        # this key is set in LabelContainer, when present
-                        # we can associate the label with the widget it is labelling
-                        curr_label._xmlui_for_name = name
-
-            else:
-                raise NotImplementedError(_("Unknown tag [%s]") % node.nodeName)
-
-    def construct_ui(self, parsed_dom, post_treat=None):
-        """Actually construct the UI
-
-        @param parsed_dom: main parsed dom
-        @param post_treat: frontend specific treatments to do once the UI is constructed
-        @return: constructed widget
-        """
-        top = parsed_dom.documentElement
-        self.type = top.getAttribute("type")
-        if top.nodeName != "sat_xmlui" or not self.type in [
-            "form",
-            "param",
-            "window",
-            "popup",
-        ]:
-            raise InvalidXMLUI
-
-        if self.type == "param":
-            self.param_changed = set()
-
-        self._parse_childs(self, parsed_dom.documentElement)
-
-        if post_treat is not None:
-            post_treat()
-
-    def _xmlui_set_param(self, name, value, category):
-        self.host.bridge.param_set(name, value, category, profile_key=self.profile)
-
-    ##EVENTS##
-
-    def on_param_change(self, ctrl):
-        """Called when type is param and a widget to save is modified
-
-        @param ctrl: widget modified
-        """
-        assert self.type == "param"
-        self.param_changed.add(ctrl)
-
-    def on_adv_list_select(self, ctrl):
-        data = {}
-        widgets = ctrl._xmlui_get_selected_widgets()
-        for wid in widgets:
-            try:
-                name = self.escape(wid._xmlui_name)
-                value = wid._xmlui_get_value()
-                data[name] = value
-            except (
-                AttributeError,
-                TypeError,
-            ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
-                pass
-        idx = ctrl._xmlui_get_selected_index()
-        if idx is not None:
-            data["index"] = idx
-        callback_id = ctrl._xmlui_callback_id
-        if callback_id is None:
-            log.info(_("No callback_id found"))
-            return
-        self._xmlui_launch_action(callback_id, data)
-
-    def on_button_press(self, button):
-        """Called when an XMLUI button is clicked
-
-        Launch the action associated to the button
-        @param button: the button clicked
-        """
-        callback_id, fields = button._xmlui_param_id
-        if not callback_id:  # the button is probably bound to an internal action
-            return
-        data = {}
-        for field in fields:
-            escaped = self.escape(field)
-            ctrl = self.ctrl_list[field]
-            if isinstance(ctrl["control"], ListWidget):
-                data[escaped] = "\t".join(ctrl["control"]._xmlui_get_selected_values())
-            else:
-                data[escaped] = ctrl["control"]._xmlui_get_value()
-        self._xmlui_launch_action(callback_id, data)
-
-    def on_change_internal(self, ctrl):
-        """Called when a widget that has been bound to an internal callback is changed.
-
-        This is used to perform some UI actions without communicating with the backend.
-        See sat.tools.xml_tools.Widget.set_internal_callback for more details.
-        @param ctrl: widget modified
-        """
-        action, fields, data = ctrl._xmlui_param_internal
-        if action not in ("copy", "move", "groups_of_contact"):
-            raise NotImplementedError(
-                _("FIXME: XMLUI internal action [%s] is not implemented") % action
-            )
-
-        def copy_move(source, target):
-            """Depending of 'action' value, copy or move from source to target."""
-            if isinstance(target, ListWidget):
-                if isinstance(source, ListWidget):
-                    values = source._xmlui_get_selected_values()
-                else:
-                    values = [source._xmlui_get_value()]
-                    if action == "move":
-                        source._xmlui_set_value("")
-                values = [value for value in values if value]
-                if values:
-                    target._xmlui_add_values(values, select=True)
-            else:
-                if isinstance(source, ListWidget):
-                    value = ", ".join(source._xmlui_get_selected_values())
-                else:
-                    value = source._xmlui_get_value()
-                    if action == "move":
-                        source._xmlui_set_value("")
-                target._xmlui_set_value(value)
-
-        def groups_of_contact(source, target):
-            """Select in target the groups of the contact which is selected in source."""
-            assert isinstance(source, ListWidget)
-            assert isinstance(target, ListWidget)
-            try:
-                contact_jid_s = source._xmlui_get_selected_values()[0]
-            except IndexError:
-                return
-            target._xmlui_select_values(data[contact_jid_s])
-            pass
-
-        source = None
-        for field in fields:
-            widget = self.ctrl_list[field]["control"]
-            if not source:
-                source = widget
-                continue
-            if action in ("copy", "move"):
-                copy_move(source, widget)
-            elif action == "groups_of_contact":
-                groups_of_contact(source, widget)
-            source = None
-
-    def get_internal_callback_data(self, action, node):
-        """Retrieve from node the data needed to perform given action.
-
-        @param action (string): a value from the one that can be passed to the
-            'callback' parameter of sat.tools.xml_tools.Widget.set_internal_callback
-        @param node (DOM Element): the node of the widget that triggers the callback
-        """
-        # TODO: it would be better to not have a specific way to retrieve
-        # data for each action, but instead to have a generic method to
-        # extract any kind of data structure from the 'internal_data' element.
-
-        try:  # data is stored in the first 'internal_data' element of the node
-            data_elts = node.getElementsByTagName("internal_data")[0].childNodes
-        except IndexError:
-            return None
-        data = {}
-        if (
-            action == "groups_of_contact"
-        ):  # return a dict(key: string, value: list[string])
-            for elt in data_elts:
-                jid_s = elt.getAttribute("name")
-                data[jid_s] = []
-                for value_elt in elt.childNodes:
-                    data[jid_s].append(value_elt.getAttribute("name"))
-        return data
-
-    def on_form_submitted(self, ignore=None):
-        """An XMLUI form has been submited
-
-        call the submit action associated with this form
-        """
-        selected_values = []
-        for ctrl_name in self.ctrl_list:
-            escaped = self.escape(ctrl_name)
-            ctrl = self.ctrl_list[ctrl_name]
-            if isinstance(ctrl["control"], ListWidget):
-                selected_values.append(
-                    (escaped, "\t".join(ctrl["control"]._xmlui_get_selected_values()))
-                )
-            else:
-                selected_values.append((escaped, ctrl["control"]._xmlui_get_value()))
-        data = dict(selected_values)
-        for key, value in self.hidden.items():
-            data[self.escape(key)] = value
-
-        if self.submit_id is not None:
-            self.submit(data)
-        else:
-            log.warning(
-                _("The form data is not sent back, the type is not managed properly")
-            )
-            self._xmlui_close()
-
-    def on_form_cancelled(self, *__):
-        """Called when a form is cancelled"""
-        log.debug(_("Cancelling form"))
-        if self.submit_id is not None:
-            data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE}
-            self.submit(data)
-        else:
-            log.warning(
-                _("The form data is not sent back, the type is not managed properly")
-            )
-        self._xmlui_close()
-
-    def on_save_params(self, ignore=None):
-        """Params are saved, we send them to backend
-
-        self.type must be param
-        """
-        assert self.type == "param"
-        for ctrl in self.param_changed:
-            if isinstance(ctrl, ListWidget):
-                value = "\t".join(ctrl._xmlui_get_selected_values())
-            else:
-                value = ctrl._xmlui_get_value()
-            param_name = ctrl._xmlui_name.split(C.SAT_PARAM_SEPARATOR)[1]
-            self._xmlui_set_param(param_name, value, ctrl._param_category)
-
-        self._xmlui_close()
-
-    def show(self, *args, **kwargs):
-        pass
-
-
-class AIOXMLUIPanel(XMLUIPanel):
-    """Asyncio compatible version of XMLUIPanel"""
-
-    async def on_form_submitted(self, ignore=None):
-        """An XMLUI form has been submited
-
-        call the submit action associated with this form
-        """
-        selected_values = []
-        for ctrl_name in self.ctrl_list:
-            escaped = self.escape(ctrl_name)
-            ctrl = self.ctrl_list[ctrl_name]
-            if isinstance(ctrl["control"], ListWidget):
-                selected_values.append(
-                    (escaped, "\t".join(ctrl["control"]._xmlui_get_selected_values()))
-                )
-            else:
-                selected_values.append((escaped, ctrl["control"]._xmlui_get_value()))
-        data = dict(selected_values)
-        for key, value in self.hidden.items():
-            data[self.escape(key)] = value
-
-        if self.submit_id is not None:
-            await self.submit(data)
-        else:
-            log.warning(
-                _("The form data is not sent back, the type is not managed properly")
-            )
-            self._xmlui_close()
-
-    async def on_form_cancelled(self, *__):
-        """Called when a form is cancelled"""
-        log.debug(_("Cancelling form"))
-        if self.submit_id is not None:
-            data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE}
-            await self.submit(data)
-        else:
-            log.warning(
-                _("The form data is not sent back, the type is not managed properly")
-            )
-        self._xmlui_close()
-
-    async def submit(self, data):
-        self._xmlui_close()
-        if self.submit_id is None:
-            raise ValueError("Can't submit is self.submit_id is not set")
-        if "session_id" in data:
-            raise ValueError(
-                "session_id must no be used in data, it is automaticaly filled with "
-                "self.session_id if present"
-            )
-        if self.session_id is not None:
-            data["session_id"] = self.session_id
-        await self._xmlui_launch_action(self.submit_id, data)
-
-    async def _xmlui_launch_action(self, action_id, data):
-        await self.host.action_launch(
-            action_id, data, callback=self.callback, profile=self.profile
-        )
-
-
-class XMLUIDialog(XMLUIBase):
-    dialog_factory = None
-
-    def __init__(
-        self,
-        host,
-        parsed_dom,
-        title=None,
-        flags=None,
-        callback=None,
-        ignore=None,
-        whitelist=None,
-        profile=C.PROF_KEY_NONE,
-    ):
-        super(XMLUIDialog, self).__init__(
-            host, parsed_dom, title=title, flags=flags, callback=callback, profile=profile
-        )
-        top = parsed_dom.documentElement
-        dlg_elt = self._get_child_node(top, "dialog")
-        if dlg_elt is None:
-            raise ValueError("Invalid XMLUI: no Dialog element found !")
-        dlg_type = dlg_elt.getAttribute("type") or C.XMLUI_DIALOG_MESSAGE
-        try:
-            mess_elt = self._get_child_node(dlg_elt, C.XMLUI_DATA_MESS)
-            message = get_text(mess_elt)
-        except (
-            TypeError,
-            AttributeError,
-        ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
-            message = ""
-        level = dlg_elt.getAttribute(C.XMLUI_DATA_LVL) or C.XMLUI_DATA_LVL_INFO
-
-        if dlg_type == C.XMLUI_DIALOG_MESSAGE:
-            self.dlg = self.dialog_factory.createMessageDialog(
-                self, self.xmlui_title, message, level
-            )
-        elif dlg_type == C.XMLUI_DIALOG_NOTE:
-            self.dlg = self.dialog_factory.createNoteDialog(
-                self, self.xmlui_title, message, level
-            )
-        elif dlg_type == C.XMLUI_DIALOG_CONFIRM:
-            try:
-                buttons_elt = self._get_child_node(dlg_elt, "buttons")
-                buttons_set = (
-                    buttons_elt.getAttribute("set") or C.XMLUI_DATA_BTNS_SET_DEFAULT
-                )
-            except (
-                TypeError,
-                AttributeError,
-            ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
-                buttons_set = C.XMLUI_DATA_BTNS_SET_DEFAULT
-            self.dlg = self.dialog_factory.createConfirmDialog(
-                self, self.xmlui_title, message, level, buttons_set
-            )
-        elif dlg_type == C.XMLUI_DIALOG_FILE:
-            try:
-                file_elt = self._get_child_node(dlg_elt, "file")
-                filetype = file_elt.getAttribute("type") or C.XMLUI_DATA_FILETYPE_DEFAULT
-            except (
-                TypeError,
-                AttributeError,
-            ):  # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError
-                filetype = C.XMLUI_DATA_FILETYPE_DEFAULT
-            self.dlg = self.dialog_factory.createFileDialog(
-                self, self.xmlui_title, message, level, filetype
-            )
-        else:
-            raise ValueError("Unknown dialog type [%s]" % dlg_type)
-
-    def show(self):
-        self.dlg._xmlui_show()
-
-    def _xmlui_close(self):
-        self.dlg._xmlui_close()
-
-
-def register_class(type_, class_):
-    """Register the class to use with the factory
-
-    @param type_: one of:
-        CLASS_PANEL: classical XMLUI interface
-        CLASS_DIALOG: XMLUI dialog
-    @param class_: the class to use to instanciate given type
-    """
-    # TODO: remove this method, as there are seme use cases where different XMLUI
-    #       classes can be used in the same frontend, so a global value is not good
-    assert type_ in (CLASS_PANEL, CLASS_DIALOG)
-    log.warning("register_class for XMLUI is deprecated, please use partial with "
-                "xmlui.create and class_map instead")
-    if type_ in _class_map:
-        log.debug(_("XMLUI class already registered for {type_}, ignoring").format(
-            type_=type_))
-        return
-
-    _class_map[type_] = class_
-
-
-def create(host, xml_data, title=None, flags=None, dom_parse=None, dom_free=None,
-           callback=None, ignore=None, whitelist=None, class_map=None,
-           profile=C.PROF_KEY_NONE):
-    """
-        @param dom_parse: methode equivalent to minidom.parseString (but which must manage unicode), or None to use default one
-        @param dom_free: method used to free the parsed DOM
-        @param ignore(list[unicode], None): name of widgets to ignore
-            widgets with name in this list and their label will be ignored
-        @param whitelist(list[unicode], None): name of widgets to keep
-            when not None, only widgets in this list and their label will be kept
-            mutually exclusive with ignore
-    """
-    if class_map is None:
-        class_map = _class_map
-    if dom_parse is None:
-        from xml.dom import minidom
-
-        dom_parse = lambda xml_data: minidom.parseString(xml_data.encode("utf-8"))
-        dom_free = lambda parsed_dom: parsed_dom.unlink()
-    else:
-        dom_parse = dom_parse
-        dom_free = dom_free or (lambda parsed_dom: None)
-    parsed_dom = dom_parse(xml_data)
-    top = parsed_dom.documentElement
-    ui_type = top.getAttribute("type")
-    try:
-        if ui_type != C.XMLUI_DIALOG:
-            cls = class_map[CLASS_PANEL]
-        else:
-            cls = class_map[CLASS_DIALOG]
-    except KeyError:
-        raise ClassNotRegistedError(
-            _("You must register classes with register_class before creating a XMLUI")
-        )
-
-    xmlui = cls(
-        host,
-        parsed_dom,
-        title=title,
-        flags=flags,
-        callback=callback,
-        ignore=ignore,
-        whitelist=whitelist,
-        profile=profile,
-    )
-    dom_free(parsed_dom)
-    return xmlui
--- a/setup.py	Fri Jun 02 12:59:21 2023 +0200
+++ b/setup.py	Fri Jun 02 14:12:38 2023 +0200
@@ -132,13 +132,13 @@
             "sat = libervia.backend.core.launcher:Launcher.run",
 
             # CLI + aliases
-            "libervia-cli = sat_frontends.jp.base:LiberviaCli.run",
-            "li = sat_frontends.jp.base:LiberviaCli.run",
-            "jp = sat_frontends.jp.base:LiberviaCli.run",
+            "libervia-cli = libervia.frontends.jp.base:LiberviaCli.run",
+            "li = libervia.frontends.jp.base:LiberviaCli.run",
+            "jp = libervia.frontends.jp.base:LiberviaCli.run",
 
             # TUI + alias
-            "libervia-tui = sat_frontends.primitivus.base:PrimitivusApp.run",
-            "primitivus = sat_frontends.primitivus.base:PrimitivusApp.run",
+            "libervia-tui = libervia.frontends.primitivus.base:PrimitivusApp.run",
+            "primitivus = libervia.frontends.primitivus.base:PrimitivusApp.run",
             ],
         },
     zip_safe=False,