Mercurial > libervia-backend
changeset 4074:26b7ed2817da
refactoring: rename `sat_frontends` to `libervia.frontends`
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,