Mercurial > libervia-backend
diff libervia/backend/tools/xml_tools.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/tools/xml_tools.py@2594e1951cf7 |
children | b274f0d5c138 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/backend/tools/xml_tools.py Fri Jun 02 11:49:51 2023 +0200 @@ -0,0 +1,2093 @@ +#!/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/>. + + +from collections import OrderedDict +import html.entities +import re +from typing import Dict, Optional, Tuple, Union, Literal, overload, Iterable +from xml.dom import NotFoundErr, minidom +import xml.etree.ElementTree as ET +from lxml import etree + +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from twisted.words.xish import domish +from wokkel import data_form + +from libervia.backend.core import exceptions +from libervia.backend.core.constants import Const as C +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger + + +log = getLogger(__name__) + +"""This library help manage XML used in SàT (parameters, registration, etc)""" + +SAT_FORM_PREFIX = "SAT_FORM_" +SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_" # used to have unique elements names +html_entity_re = re.compile(r"&([a-zA-Z]+?);") +XML_ENTITIES = ("quot", "amp", "apos", "lt", "gt") + +# method to clean XHTML, receive raw unsecure XML or HTML, must return cleaned raw XHTML +# this method must be set during runtime +clean_xhtml = None + +# TODO: move XMLUI stuff in a separate module +# TODO: rewrite this with lxml or ElementTree or domish.Element: it's complicated and difficult to maintain with current minidom implementation + +# Helper functions + + +def _data_form_field_2_xmlui_data(field, read_only=False): + """Get data needed to create an XMLUI's Widget from Wokkel's data_form's Field. + + The attribute field can be modified (if it's fixed and it has no value). + @param field (data_form.Field): a field with attributes "value", "fieldType", + "label" and "var" + @param read_only (bool): if True and it makes sense, create a read only input widget + @return: a tuple (widget_type, widget_args, widget_kwargs) + """ + widget_args = field.values or [None] + widget_kwargs = {} + if field.fieldType is None and field.ext_type is not None: + # we have an extended field + if field.ext_type == "xml": + element = field.value + if element.uri == C.NS_XHTML: + widget_type = "xhtmlbox" + widget_args[0] = element.toXml() + widget_kwargs["read_only"] = read_only + else: + log.warning("unknown XML element, falling back to textbox") + widget_type = "textbox" + widget_args[0] = element.toXml() + widget_kwargs["read_only"] = read_only + else: + + raise exceptions.DataError("unknown extended type {ext_type}".format( + ext_type = field.ext_type)) + + elif field.fieldType == "fixed" or field.fieldType is None: + widget_type = "text" + if field.value is None: + if field.label is None: + log.warning(_("Fixed field has neither value nor label, ignoring it")) + field.value = "" + else: + field.value = field.label + field.label = None + widget_args = [field.value] + elif field.fieldType == "text-single": + widget_type = "string" + widget_kwargs["read_only"] = read_only + elif field.fieldType == "jid-single": + widget_type = "jid_input" + widget_kwargs["read_only"] = read_only + elif field.fieldType == "text-multi": + widget_type = "textbox" + widget_args = ["\n".join(field.values)] + widget_kwargs["read_only"] = read_only + elif field.fieldType == "hidden": + widget_type = "hidden" + elif field.fieldType == "text-private": + widget_type = "password" + widget_kwargs["read_only"] = read_only + elif field.fieldType == "boolean": + widget_type = "bool" + if widget_args[0] is None: + widget_args = ["false"] + widget_kwargs["read_only"] = read_only + elif field.fieldType == "integer": + widget_type = "integer" + widget_kwargs["read_only"] = read_only + elif field.fieldType == "list-single": + widget_type = "list" + widget_kwargs["options"] = [ + (option.value, option.label or option.value) for option in field.options + ] + widget_kwargs["selected"] = widget_args + widget_args = [] + elif field.fieldType == "list-multi": + widget_type = "list" + widget_kwargs["options"] = [ + (option.value, option.label or option.value) for option in field.options + ] + widget_kwargs["selected"] = widget_args + widget_kwargs["styles"] = ["multi"] + widget_args = [] + else: + log.error( + "FIXME FIXME FIXME: Type [%s] is not managed yet by SàT" % field.fieldType + ) + widget_type = "string" + widget_kwargs["read_only"] = read_only + + if field.var: + widget_kwargs["name"] = field.var + + return widget_type, widget_args, widget_kwargs + +def data_form_2_widgets(form_ui, form, read_only=False, prepend=None, filters=None): + """Complete an existing XMLUI with widget converted from XEP-0004 data forms. + + @param form_ui (XMLUI): XMLUI instance + @param form (data_form.Form): Wokkel's implementation of data form + @param read_only (bool): if True and it makes sense, create a read only input widget + @param prepend(iterable, None): widgets to prepend to main LabelContainer + if not None, must be an iterable of *args for add_widget. Those widgets will + be added first to the container. + @param filters(dict, None): if not None, a dictionary of callable: + key is the name of the widget to filter + the value is a callable, it will get form's XMLUI, widget's type, args and kwargs + and must return widget's type, args and kwargs (which can be modified) + This is especially useful to modify well known fields + @return: the completed XMLUI instance + """ + if filters is None: + filters = {} + if form.instructions: + form_ui.addText("\n".join(form.instructions), "instructions") + + form_ui.change_container("label") + + if prepend is not None: + for widget_args in prepend: + form_ui.add_widget(*widget_args) + + for field in form.fieldList: + widget_type, widget_args, widget_kwargs = _data_form_field_2_xmlui_data( + field, read_only + ) + try: + widget_filter = filters[widget_kwargs["name"]] + except KeyError: + pass + else: + widget_type, widget_args, widget_kwargs = widget_filter( + form_ui, widget_type, widget_args, widget_kwargs + ) + if widget_type != "hidden": + label = field.label or field.var + if label: + form_ui.addLabel(label) + else: + form_ui.addEmpty() + + form_ui.add_widget(widget_type, *widget_args, **widget_kwargs) + + return form_ui + + +def data_form_2_xmlui(form, submit_id, session_id=None, read_only=False): + """Take a data form (Wokkel's XEP-0004 implementation) and convert it to a SàT XMLUI. + + @param form (data_form.Form): a Form instance + @param submit_id (unicode): callback id to call when submitting form + @param session_id (unicode): session id to return with the data + @param read_only (bool): if True and it makes sense, create a read only input widget + @return: XMLUI instance + """ + form_ui = XMLUI("form", "vertical", submit_id=submit_id, session_id=session_id) + return data_form_2_widgets(form_ui, form, read_only=read_only) + + +def data_form_2_data_dict(form: data_form.Form) -> dict: + """Convert data form to a simple dict, easily serialisable + + see data_dict_2_data_form for a description of the format + """ + fields = [] + data_dict = { + "fields": fields + } + if form.formNamespace: + data_dict["namespace"] = form.formNamespace + for form_field in form.fieldList: + field = {"type": form_field.fieldType} + fields.append(field) + for src_name, dest_name in ( + ('var', 'name'), + ('label', 'label'), + ('value', 'value'), + # FIXME: we probably should have only "values" + ('values', 'values') + ): + value = getattr(form_field, src_name, None) + if value: + field[dest_name] = value + if form_field.options: + options = field["options"] = [] + for form_opt in form_field.options: + opt = {"value": form_opt.value} + if form_opt.label: + opt["label"] = form_opt.label + options.append(opt) + + if form_field.fieldType is None and form_field.ext_type == "xml": + if isinstance(form_field.value, domish.Element): + if ((form_field.value.uri == C.NS_XHTML + and form_field.value.name == "div")): + field["type"] = "xhtml" + if form_field.value.children: + log.warning( + "children are not managed for XHTML fields: " + f"{form_field.value.toXml()}" + ) + return data_dict + + +def data_dict_2_data_form(data_dict): + """Convert serialisable dict of data to a data form + + The format of the dict is as follow: + - an optional "namespace" key with form namespace + - a mandatory "fields" key with list of fields as follow: + - "type" is mostly the same as data_form.Field.fieldType + - "name" is used to set the "var" attribute of data_form.Field + - "label", and "value" follow same attribude in data_form.Field + - "xhtml" is used for "xml" fields with child in the C.NS_XHTML namespace + - "options" are list of dict with optional "label" and mandatory "value" + following suitable attributes from data_form.Option + - "required" is the same as data_form.Field.required + """ + # TODO: describe format + fields = [] + for field_data in data_dict["fields"]: + field_type = field_data.get('type', 'text-single') + kwargs = { + "fieldType": field_type, + "var": field_data["name"], + "label": field_data.get('label'), + "value": field_data.get("value"), + "required": field_data.get("required") + } + if field_type == "xhtml": + kwargs.update({ + "fieldType": None, + "ext_type": "xml", + }) + if kwargs["value"] is None: + kwargs["value"] = domish.Element((C.NS_XHTML, "div")) + elif "options" in field_data: + kwargs["options"] = [ + data_form.Option(o["value"], o.get("label")) + for o in field_data["options"] + ] + field = data_form.Field(**kwargs) + fields.append(field) + return data_form.Form( + "form", + formNamespace=data_dict.get("namespace"), + fields=fields + ) + + +def data_form_elt_result_2_xmlui_data(form_xml): + """Parse a data form result (not parsed by Wokkel's XEP-0004 implementation). + + The raw data form is used because Wokkel doesn't manage result items parsing yet. + @param form_xml (domish.Element): element of the data form + @return: a couple (headers, result_list): + - headers (dict{unicode: unicode}): form headers (field labels and types) + - xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs) + """ + headers = OrderedDict() + try: + reported_elt = next(form_xml.elements("jabber:x:data", "reported")) + except StopIteration: + raise exceptions.DataError( + "Couldn't find expected <reported> tag in %s" % form_xml.toXml() + ) + + for elt in reported_elt.elements(): + if elt.name != "field": + raise exceptions.DataError("Unexpected tag") + name = elt["var"] + label = elt.attributes.get("label", "") + type_ = elt.attributes.get("type") + headers[name] = (label, type_) + + if not headers: + raise exceptions.DataError("No reported fields (see XEP-0004 §3.4)") + + xmlui_data = [] + item_elts = form_xml.elements("jabber:x:data", "item") + + for item_elt in item_elts: + for elt in item_elt.elements(): + if elt.name != "field": + log.warning("Unexpected tag (%s)" % elt.name) + continue + field = data_form.Field.fromElement(elt) + + xmlui_data.append(_data_form_field_2_xmlui_data(field)) + + return headers, xmlui_data + + +def xmlui_data_2_advanced_list(xmlui, headers, xmlui_data): + """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list. + + The raw data form is used because Wokkel doesn't manage result items parsing yet. + @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added + @param headers (dict{unicode: unicode}): form headers (field labels and types) + @param xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs) + @return: the completed XMLUI instance + """ + adv_list = AdvancedListContainer( + xmlui, headers=headers, columns=len(headers), parent=xmlui.current_container + ) + xmlui.change_container(adv_list) + + for widget_type, widget_args, widget_kwargs in xmlui_data: + xmlui.add_widget(widget_type, *widget_args, **widget_kwargs) + + return xmlui + + +def data_form_result_2_advanced_list(xmlui, form_xml): + """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list. + + The raw data form is used because Wokkel doesn't manage result items parsing yet. + @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added + @param form_xml (domish.Element): element of the data form + @return: the completed XMLUI instance + """ + headers, xmlui_data = data_form_elt_result_2_xmlui_data(form_xml) + xmlui_data_2_advanced_list(xmlui, headers, xmlui_data) + + +def data_form_elt_result_2_xmlui(form_elt, session_id=None): + """Take a raw data form (not parsed by XEP-0004) and convert it to a SàT XMLUI. + + The raw data form is used because Wokkel doesn't manage result items parsing yet. + @param form_elt (domish.Element): element of the data form + @param session_id (unicode): session id to return with the data + @return: XMLUI instance + """ + xml_ui = XMLUI("window", "vertical", session_id=session_id) + try: + data_form_result_2_advanced_list(xml_ui, form_elt) + except exceptions.DataError: + parsed_form = data_form.Form.fromElement(form_elt) + data_form_2_widgets(xml_ui, parsed_form, read_only=True) + return xml_ui + + +def data_form_result_2_xmlui(result_form, base_form, session_id=None, prepend=None, + filters=None, read_only=True): + """Convert data form result to SàT XMLUI. + + @param result_form (data_form.Form): result form to convert + @param base_form (data_form.Form): initial form (i.e. of form type "form") + this one is necessary to reconstruct options when needed (e.g. list elements) + @param session_id (unicode): session id to return with the data + @param prepend: same as for [data_form_2_widgets] + @param filters: same as for [data_form_2_widgets] + @param read_only: same as for [data_form_2_widgets] + @return: XMLUI instance + """ + # we deepcopy the form because _data_form_field_2_xmlui_data can modify the value + # FIXME: check if it's really important, the only modified value seems to be + # the replacement of None by "" on fixed fields + # form = deepcopy(result_form) + form = result_form + for name, field in form.fields.items(): + try: + base_field = base_form.fields[name] + except KeyError: + continue + field.options = base_field.options[:] + xml_ui = XMLUI("window", "vertical", session_id=session_id) + data_form_2_widgets(xml_ui, form, read_only=read_only, prepend=prepend, filters=filters) + return xml_ui + + +def _clean_value(value): + """Workaround method to avoid DBus types with D-Bus bridge. + + @param value: value to clean + @return: value in a non DBus type (only clean string yet) + """ + # XXX: must be removed when DBus types will no cause problems anymore + # FIXME: should be cleaned inside D-Bus bridge itself + if isinstance(value, str): + return str(value) + return value + + +def xmlui_result_2_data_form_result(xmlui_data): + """ Extract form data from a XMLUI return. + + @param xmlui_data (dict): data returned by frontends for XMLUI form + @return: dict of data usable by Wokkel's data form + """ + ret = {} + for key, value in xmlui_data.items(): + if not key.startswith(SAT_FORM_PREFIX): + continue + if isinstance(value, str): + if "\n" in value: + # data form expects multi-lines text to be in separated values + value = value.split('\n') + elif "\t" in value: + # FIXME: workaround to handle multiple values. Proper serialisation must + # be done in XMLUI + value = value.split("\t") + ret[key[len(SAT_FORM_PREFIX) :]] = _clean_value(value) + return ret + + +def form_escape(name): + """Return escaped name for forms. + + @param name (unicode): form name + @return: unicode + """ + return "%s%s" % (SAT_FORM_PREFIX, name) + + +def is_xmlui_cancelled(raw_xmlui): + """Tell if an XMLUI has been cancelled by checking raw XML""" + return C.bool(raw_xmlui.get('cancelled', C.BOOL_FALSE)) + + +def xmlui_result_to_elt(xmlui_data): + """Construct result domish.Element from XMLUI result. + + @param xmlui_data (dict): data returned by frontends for XMLUI form + @return: domish.Element + """ + form = data_form.Form("submit") + form.makeFields(xmlui_result_2_data_form_result(xmlui_data)) + return form.toElement() + + +def tuple_list_2_data_form(values): + """Convert a list of tuples (name, value) to a wokkel submit data form. + + @param values (list): list of tuples + @return: data_form.Form + """ + form = data_form.Form("submit") + for value in values: + field = data_form.Field(var=value[0], value=value[1]) + form.addField(field) + + return form + + +def params_xml_2_xmlui(xml): + """Convert the XML for parameter to a SàT XML User Interface. + + @param xml (unicode) + @return: XMLUI + """ + # TODO: refactor params and use Twisted directly to parse XML + params_doc = minidom.parseString(xml.encode("utf-8")) + top = params_doc.documentElement + if top.nodeName != "params": + raise exceptions.DataError(_("INTERNAL ERROR: parameters xml not valid")) + + param_ui = XMLUI("param", "tabs") + tabs_cont = param_ui.current_container + + for category in top.getElementsByTagName("category"): + category_name = category.getAttribute("name") + label = category.getAttribute("label") + if not category_name: + raise exceptions.DataError( + _("INTERNAL ERROR: params categories must have a name") + ) + tabs_cont.add_tab(category_name, label=label, container=LabelContainer) + for param in category.getElementsByTagName("param"): + widget_kwargs = {} + + param_name = param.getAttribute("name") + param_label = param.getAttribute("label") + type_ = param.getAttribute("type") + if not param_name and type_ != "text": + raise exceptions.DataError(_("INTERNAL ERROR: params must have a name")) + + value = param.getAttribute("value") or None + callback_id = param.getAttribute("callback_id") or None + + if type_ == "list": + options, selected = _params_get_list_options(param) + widget_kwargs["options"] = options + widget_kwargs["selected"] = selected + widget_kwargs["styles"] = ["extensible"] + elif type_ == "jids_list": + widget_kwargs["jids"] = _params_get_list_jids(param) + + if type_ in ("button", "text"): + param_ui.addEmpty() + value = param_label + else: + param_ui.addLabel(param_label or param_name) + + if value: + widget_kwargs["value"] = value + + if callback_id: + widget_kwargs["callback_id"] = callback_id + others = [ + "%s%s%s" + % (category_name, SAT_PARAM_SEPARATOR, other.getAttribute("name")) + for other in category.getElementsByTagName("param") + if other.getAttribute("type") != "button" + ] + widget_kwargs["fields_back"] = others + + widget_kwargs["name"] = "%s%s%s" % ( + category_name, + SAT_PARAM_SEPARATOR, + param_name, + ) + + param_ui.add_widget(type_, **widget_kwargs) + + return param_ui.toXml() + + +def _params_get_list_options(param): + """Retrieve the options for list element. + + The <option/> tags must be direct children of <param/>. + @param param (domish.Element): element + @return: a tuple (options, selected_value) + """ + if len(param.getElementsByTagName("options")) > 0: + raise exceptions.DataError( + _("The 'options' tag is not allowed in parameter of type 'list'!") + ) + elems = param.getElementsByTagName("option") + if len(elems) == 0: + return [] + options = [] + for elem in elems: + value = elem.getAttribute("value") + if not value: + raise exceptions.InternalError("list option must have a value") + label = elem.getAttribute("label") + if label: + options.append((value, label)) + else: + options.append(value) + selected = [ + elem.getAttribute("value") + for elem in elems + if elem.getAttribute("selected") == "true" + ] + return (options, selected) + + +def _params_get_list_jids(param): + """Retrive jids from a jids_list element. + + the <jid/> tags must be direct children of <param/> + @param param (domish.Element): element + @return: a list of jids + """ + elems = param.getElementsByTagName("jid") + jids = [ + elem.firstChild.data + for elem in elems + if elem.firstChild is not None and elem.firstChild.nodeType == elem.TEXT_NODE + ] + return jids + + +### XMLUI Elements ### + + +class Element(object): + """ Base XMLUI element """ + + type = None + + def __init__(self, xmlui, parent=None): + """Create a container element + + @param xmlui: XMLUI instance + @parent: parent element + """ + assert self.type is not None + self.children = [] + if not hasattr(self, "elem"): + self.elem = parent.xmlui.doc.createElement(self.type) + self.xmlui = xmlui + if parent is not None: + parent.append(self) + self.parent = parent + + def append(self, child): + """Append a child to this element. + + @param child (Element): child element + @return: the added child Element + """ + self.elem.appendChild(child.elem) + child.parent = self + self.children.append(child) + return child + + +class TopElement(Element): + """ Main XML Element """ + + type = "top" + + def __init__(self, xmlui): + self.elem = xmlui.doc.documentElement + super(TopElement, self).__init__(xmlui) + + +class TabElement(Element): + """ Used by TabsContainer to give name and label to tabs.""" + + type = "tab" + + def __init__(self, parent, name, label, selected=False): + """ + + @param parent (TabsContainer): parent container + @param name (unicode): tab name + @param label (unicode): tab label + @param selected (bool): set to True to select this tab + """ + if not isinstance(parent, TabsContainer): + raise exceptions.DataError(_("TabElement must be a child of TabsContainer")) + super(TabElement, self).__init__(parent.xmlui, parent) + self.elem.setAttribute("name", name) + self.elem.setAttribute("label", label) + if selected: + self.set_selected(selected) + + def set_selected(self, selected=False): + """Set the tab selected. + + @param selected (bool): set to True to select this tab + """ + self.elem.setAttribute("selected", "true" if selected else "false") + + +class FieldBackElement(Element): + """ Used by ButtonWidget to indicate which field have to be sent back """ + + type = "field_back" + + def __init__(self, parent, name): + assert isinstance(parent, ButtonWidget) + super(FieldBackElement, self).__init__(parent.xmlui, parent) + self.elem.setAttribute("name", name) + + +class InternalFieldElement(Element): + """ Used by internal callbacks to indicate which fields are manipulated """ + + type = "internal_field" + + def __init__(self, parent, name): + super(InternalFieldElement, self).__init__(parent.xmlui, parent) + self.elem.setAttribute("name", name) + + +class InternalDataElement(Element): + """ Used by internal callbacks to retrieve extra data """ + + type = "internal_data" + + def __init__(self, parent, children): + super(InternalDataElement, self).__init__(parent.xmlui, parent) + assert isinstance(children, list) + for child in children: + self.elem.childNodes.append(child) + + +class OptionElement(Element): + """" Used by ListWidget to specify options """ + + type = "option" + + def __init__(self, parent, option, selected=False): + """ + + @param parent + @param option (string, tuple) + @param selected (boolean) + """ + assert isinstance(parent, ListWidget) + super(OptionElement, self).__init__(parent.xmlui, parent) + if isinstance(option, str): + value, label = option, option + elif isinstance(option, tuple): + value, label = option + else: + raise NotImplementedError + self.elem.setAttribute("value", value) + self.elem.setAttribute("label", label) + if selected: + self.elem.setAttribute("selected", "true") + + +class JidElement(Element): + """" Used by JidsListWidget to specify jids""" + + type = "jid" + + def __init__(self, parent, jid_): + """ + @param jid_(jid.JID, unicode): jid to append + """ + assert isinstance(parent, JidsListWidget) + super(JidElement, self).__init__(parent.xmlui, parent) + if isinstance(jid_, jid.JID): + value = jid_.full() + elif isinstance(jid_, str): + value = str(jid_) + else: + raise NotImplementedError + jid_txt = self.xmlui.doc.createTextNode(value) + self.elem.appendChild(jid_txt) + + +class RowElement(Element): + """" Used by AdvancedListContainer """ + + type = "row" + + def __init__(self, parent): + assert isinstance(parent, AdvancedListContainer) + super(RowElement, self).__init__(parent.xmlui, parent) + if parent.next_row_idx is not None: + if parent.auto_index: + raise exceptions.DataError(_("Can't set row index if auto_index is True")) + self.elem.setAttribute("index", parent.next_row_idx) + parent.next_row_idx = None + + +class HeaderElement(Element): + """" Used by AdvancedListContainer """ + + type = "header" + + def __init__(self, parent, name=None, label=None, description=None): + """ + @param parent: AdvancedListContainer instance + @param name: name of the container + @param label: label to be displayed in columns + @param description: long descriptive text + """ + assert isinstance(parent, AdvancedListContainer) + super(HeaderElement, self).__init__(parent.xmlui, parent) + if name: + self.elem.setAttribute("name", name) + if label: + self.elem.setAttribute("label", label) + if description: + self.elem.setAttribute("description", description) + + +## Containers ## + + +class Container(Element): + """ And Element which contains other ones and has a layout """ + + type = None + + def __init__(self, xmlui, parent=None): + """Create a container element + + @param xmlui: XMLUI instance + @parent: parent element or None + """ + self.elem = xmlui.doc.createElement("container") + super(Container, self).__init__(xmlui, parent) + self.elem.setAttribute("type", self.type) + + def get_parent_container(self): + """ Return first parent container + + @return: parent container or None + """ + current = self.parent + while not isinstance(current, (Container)) and current is not None: + current = current.parent + return current + + +class VerticalContainer(Container): + type = "vertical" + + +class HorizontalContainer(Container): + type = "horizontal" + + +class PairsContainer(Container): + """Container with series of 2 elements""" + type = "pairs" + + +class LabelContainer(Container): + """Like PairsContainer, but first element can only be a label""" + type = "label" + + +class TabsContainer(Container): + type = "tabs" + + def add_tab(self, name, label=None, selected=None, container=VerticalContainer): + """Add a tab. + + @param name (unicode): tab name + @param label (unicode): tab label + @param selected (bool): set to True to select this tab + @param container (class): container class, inheriting from Container + @return: the container for the new tab + """ + if not label: + label = name + tab_elt = TabElement(self, name, label, selected) + new_container = container(self.xmlui, tab_elt) + return self.xmlui.change_container(new_container) + + def end(self): + """ Called when we have finished tabs + + change current container to first container parent + """ + parent_container = self.get_parent_container() + self.xmlui.change_container(parent_container) + + +class AdvancedListContainer(Container): + """A list which can contain other widgets, headers, etc""" + + type = "advanced_list" + + def __init__( + self, + xmlui, + callback_id=None, + name=None, + headers=None, + items=None, + columns=None, + selectable="no", + auto_index=False, + parent=None, + ): + """Create an advanced list + + @param headers: optional headers information + @param callback_id: id of the method to call when selection is done + @param items: list of widgets to add (just the first row) + @param columns: number of columns in this table, or None to autodetect + @param selectable: one of: + 'no': nothing is done + 'single': one row can be selected + @param auto_index: if True, indexes will be generated by frontends, + starting from 0 + @return: created element + """ + assert selectable in ("no", "single") + if not items and columns is None: + raise exceptions.DataError(_("either items or columns need do be filled")) + if headers is None: + headers = [] + if items is None: + items = [] + super(AdvancedListContainer, self).__init__(xmlui, parent) + if columns is None: + columns = len(items[0]) + self._columns = columns + self._item_idx = 0 + self.current_row = None + if headers: + if len(headers) != self._columns: + raise exceptions.DataError( + _("Headers lenght doesn't correspond to columns") + ) + self.add_headers(headers) + if items: + self.add_items(items) + self.elem.setAttribute("columns", str(self._columns)) + if callback_id is not None: + self.elem.setAttribute("callback", callback_id) + self.elem.setAttribute("selectable", selectable) + self.auto_index = auto_index + if auto_index: + self.elem.setAttribute("auto_index", "true") + self.next_row_idx = None + + def add_headers(self, headers): + for header in headers: + self.addHeader(header) + + def addHeader(self, header): + pass # TODO + + def add_items(self, items): + for item in items: + self.append(item) + + def set_row_index(self, idx): + """ Set index for next row + + index are returned when a row is selected, in data's "index" key + @param idx: string index to associate to the next row + """ + self.next_row_idx = idx + + def append(self, child): + if isinstance(child, RowElement): + return super(AdvancedListContainer, self).append(child) + if self._item_idx % self._columns == 0: + self.current_row = RowElement(self) + self.current_row.append(child) + self._item_idx += 1 + + def end(self): + """ Called when we have finished list + + change current container to first container parent + """ + if self._item_idx % self._columns != 0: + raise exceptions.DataError(_("Incorrect number of items in list")) + parent_container = self.get_parent_container() + self.xmlui.change_container(parent_container) + + +## Widgets ## + + +class Widget(Element): + type = None + + def __init__(self, xmlui, name=None, parent=None): + """Create an element + + @param xmlui: XMLUI instance + @param name: name of the element or None + @param parent: parent element or None + """ + self.elem = xmlui.doc.createElement("widget") + super(Widget, self).__init__(xmlui, parent) + if name: + self.elem.setAttribute("name", name) + if name in xmlui.named_widgets: + raise exceptions.ConflictError( + _('A widget with the name "{name}" already exists.').format( + name=name + ) + ) + xmlui.named_widgets[name] = self + self.elem.setAttribute("type", self.type) + + def set_internal_callback(self, callback, fields, data_elts=None): + """Set an internal UI callback when the widget value is changed. + + The internal callbacks are NO callback ids, they are strings from + a predefined set of actions that are running in the scope of XMLUI. + @param callback (string): a value from: + - 'copy': process the widgets given in 'fields' two by two, by + copying the values of one widget to the other. Target widgets + of type List do not accept the empty value. + - 'move': same than copy but moves the values if the source widget + is not a List. + - 'groups_of_contact': process the widgets two by two, assume A is + is a list of JID and B a list of groups, select in B the groups + to which the JID selected in A belongs. + - more operation to be added when necessary... + @param fields (list): a list of widget names (string) + @param data_elts (list[Element]): extra data elements + """ + self.elem.setAttribute("internal_callback", callback) + if fields: + for field in fields: + InternalFieldElement(self, field) + if data_elts: + InternalDataElement(self, data_elts) + + +class EmptyWidget(Widget): + """Place holder widget""" + + type = "empty" + + +class TextWidget(Widget): + """Used for blob of text""" + + type = "text" + + def __init__(self, xmlui, value, name=None, parent=None): + super(TextWidget, self).__init__(xmlui, name, parent) + value_elt = self.xmlui.doc.createElement("value") + text = self.xmlui.doc.createTextNode(value) + value_elt.appendChild(text) + self.elem.appendChild(value_elt) + + @property + def value(self): + return self.elem.firstChild.firstChild.wholeText + + +class LabelWidget(Widget): + """One line blob of text + + used most of time to display the desciption or name of the next widget + """ + type = "label" + + def __init__(self, xmlui, label, name=None, parent=None): + super(LabelWidget, self).__init__(xmlui, name, parent) + self.elem.setAttribute("value", label) + + +class HiddenWidget(Widget): + """Not displayed widget, frontends will just copy the value(s)""" + type = "hidden" + + def __init__(self, xmlui, value, name, parent=None): + super(HiddenWidget, self).__init__(xmlui, name, parent) + value_elt = self.xmlui.doc.createElement("value") + text = self.xmlui.doc.createTextNode(value) + value_elt.appendChild(text) + self.elem.appendChild(value_elt) + + @property + def value(self): + return self.elem.firstChild.firstChild.wholeText + + +class JidWidget(Widget): + """Used to display a Jabber ID, some specific methods can be added""" + + type = "jid" + + def __init__(self, xmlui, jid, name=None, parent=None): + super(JidWidget, self).__init__(xmlui, name, parent) + try: + self.elem.setAttribute("value", jid.full()) + except AttributeError: + self.elem.setAttribute("value", str(jid)) + + @property + def value(self): + return self.elem.getAttribute("value") + + +class DividerWidget(Widget): + type = "divider" + + def __init__(self, xmlui, style="line", name=None, parent=None): + """ Create a divider + + @param xmlui: XMLUI instance + @param style: one of: + - line: a simple line + - dot: a line of dots + - dash: a line of dashes + - plain: a full thick line + - blank: a blank line/space + @param name: name of the widget + @param parent: parent container + + """ + super(DividerWidget, self).__init__(xmlui, name, parent) + self.elem.setAttribute("style", style) + + +### Inputs ### + + +class InputWidget(Widget): + """Widget which can accept user inputs + + used mainly in forms + """ + + def __init__(self, xmlui, name=None, parent=None, read_only=False): + super(InputWidget, self).__init__(xmlui, name, parent) + if read_only: + self.elem.setAttribute("read_only", "true") + + +class StringWidget(InputWidget): + type = "string" + + def __init__(self, xmlui, value=None, name=None, parent=None, read_only=False): + super(StringWidget, self).__init__(xmlui, name, parent, read_only=read_only) + if value: + value_elt = self.xmlui.doc.createElement("value") + text = self.xmlui.doc.createTextNode(value) + value_elt.appendChild(text) + self.elem.appendChild(value_elt) + + @property + def value(self): + return self.elem.firstChild.firstChild.wholeText + + +class PasswordWidget(StringWidget): + type = "password" + + +class TextBoxWidget(StringWidget): + type = "textbox" + + +class XHTMLBoxWidget(StringWidget): + """Specialized textbox to manipulate XHTML""" + type = "xhtmlbox" + + def __init__(self, xmlui, value, name=None, parent=None, read_only=False, clean=True): + """ + @param clean(bool): if True, the XHTML is considered insecure and will be cleaned + Only set to False if you are absolutely sure that the XHTML is safe (in other + word, set to False only if you made the XHTML yourself) + """ + if clean: + if clean_xhtml is None: + raise exceptions.NotFound( + "No cleaning method set, can't clean the XHTML") + value = clean_xhtml(value) + + super(XHTMLBoxWidget, self).__init__( + xmlui, value=value, name=name, parent=parent, read_only=read_only) + + +class JidInputWidget(StringWidget): + type = "jid_input" + + +# TODO handle min and max values +class IntWidget(StringWidget): + type = "int" + + def __init__(self, xmlui, value=0, name=None, parent=None, read_only=False): + try: + int(value) + except ValueError: + raise exceptions.DataError(_("Value must be an integer")) + super(IntWidget, self).__init__(xmlui, value, name, parent, read_only=read_only) + + +class BoolWidget(InputWidget): + type = "bool" + + def __init__(self, xmlui, value="false", name=None, parent=None, read_only=False): + if isinstance(value, bool): + value = "true" if value else "false" + elif value == "0": + value = "false" + elif value == "1": + value = "true" + if value not in ("true", "false"): + raise exceptions.DataError(_("Value must be 0, 1, false or true")) + super(BoolWidget, self).__init__(xmlui, name, parent, read_only=read_only) + self.elem.setAttribute("value", value) + + +class ButtonWidget(Widget): + type = "button" + + def __init__( + self, xmlui, callback_id, value=None, fields_back=None, name=None, parent=None + ): + """Add a button + + @param callback_id: callback which will be called if button is pressed + @param value: label of the button + @param fields_back: list of names of field to give back when pushing the button + @param name: name + @param parent: parent container + """ + if fields_back is None: + fields_back = [] + super(ButtonWidget, self).__init__(xmlui, name, parent) + self.elem.setAttribute("callback", callback_id) + if value: + self.elem.setAttribute("value", value) + for field in fields_back: + FieldBackElement(self, field) + + +class ListWidget(InputWidget): + type = "list" + STYLES = ("multi", "noselect", "extensible", "reducible", "inline") + + def __init__( + self, xmlui, options, selected=None, styles=None, name=None, parent=None + ): + """ + + @param xmlui + @param options (list[option]): each option can be given as: + - a single string if the label and the value are the same + - a tuple with a couple of string (value,label) if the label and the + value differ + @param selected (list[string]): list of the selected values + @param styles (iterable[string]): flags to set the behaviour of the list + can be: + - multi: multiple selection is allowed + - noselect: no selection is allowed + useful when only the list itself is needed + - extensible: can be extended by user (i.e. new options can be added) + - reducible: can be reduced by user (i.e. options can be removed) + - inline: hint that this list should be displayed on a single line + (e.g. list of labels) + @param name (string) + @param parent + """ + styles = set() if styles is None else set(styles) + if styles is None: + styles = set() + else: + styles = set(styles) + if "noselect" in styles and ("multi" in styles or selected): + raise exceptions.DataError( + _( + '"multi" flag and "selected" option are not compatible with ' + '"noselect" flag' + ) + ) + if not options: + # we can have no options if we get a submitted data form + # but we can't use submitted values directly, + # because we would not have the labels + log.warning(_('empty "options" list')) + super(ListWidget, self).__init__(xmlui, name, parent) + self.add_options(options, selected) + self.set_styles(styles) + + def add_options(self, options, selected=None): + """Add options to a multi-values element (e.g. list) """ + if selected: + if isinstance(selected, str): + selected = [selected] + else: + selected = [] + for option in options: + assert isinstance(option, str) or isinstance(option, tuple) + value = option if isinstance(option, str) else option[0] + OptionElement(self, option, value in selected) + + def set_styles(self, styles): + if not styles.issubset(self.STYLES): + raise exceptions.DataError(_("invalid styles")) + for style in styles: + self.elem.setAttribute(style, "yes") + # TODO: check flags incompatibily (noselect and multi) like in __init__ + + def setStyle(self, style): + self.set_styles([style]) + + @property + def value(self): + """Return the value of first selected option""" + for child in self.elem.childNodes: + if child.tagName == "option" and child.getAttribute("selected") == "true": + return child.getAttribute("value") + return "" + + +class JidsListWidget(InputWidget): + """A list of text or jids where elements can be added/removed or modified""" + + type = "jids_list" + + def __init__(self, xmlui, jids, styles=None, name=None, parent=None): + """ + + @param xmlui + @param jids (list[jid.JID]): base jids + @param styles (iterable[string]): flags to set the behaviour of the list + @param name (string) + @param parent + """ + super(JidsListWidget, self).__init__(xmlui, name, parent) + styles = set() if styles is None else set(styles) + if not styles.issubset([]): # TODO + raise exceptions.DataError(_("invalid styles")) + for style in styles: + self.elem.setAttribute(style, "yes") + if not jids: + log.debug("empty jids list") + else: + self.add_jids(jids) + + def add_jids(self, jids): + for jid_ in jids: + JidElement(self, jid_) + + +## Dialog Elements ## + + +class DialogElement(Element): + """Main dialog element """ + + type = "dialog" + + def __init__(self, parent, type_, level=None): + if not isinstance(parent, TopElement): + raise exceptions.DataError( + _("DialogElement must be a direct child of TopElement") + ) + super(DialogElement, self).__init__(parent.xmlui, parent) + self.elem.setAttribute(C.XMLUI_DATA_TYPE, type_) + self.elem.setAttribute(C.XMLUI_DATA_LVL, level or C.XMLUI_DATA_LVL_DEFAULT) + + +class MessageElement(Element): + """Element with the instruction message""" + + type = C.XMLUI_DATA_MESS + + def __init__(self, parent, message): + if not isinstance(parent, DialogElement): + raise exceptions.DataError( + _("MessageElement must be a direct child of DialogElement") + ) + super(MessageElement, self).__init__(parent.xmlui, parent) + message_txt = self.xmlui.doc.createTextNode(message) + self.elem.appendChild(message_txt) + + +class ButtonsElement(Element): + """Buttons element which indicate which set to use""" + + type = "buttons" + + def __init__(self, parent, set_): + if not isinstance(parent, DialogElement): + raise exceptions.DataError( + _("ButtonsElement must be a direct child of DialogElement") + ) + super(ButtonsElement, self).__init__(parent.xmlui, parent) + self.elem.setAttribute("set", set_) + + +class FileElement(Element): + """File element used for FileDialog""" + + type = "file" + + def __init__(self, parent, type_): + if not isinstance(parent, DialogElement): + raise exceptions.DataError( + _("FileElement must be a direct child of DialogElement") + ) + super(FileElement, self).__init__(parent.xmlui, parent) + self.elem.setAttribute("type", type_) + + +## XMLUI main class + + +class XMLUI(object): + """This class is used to create a user interface (form/window/parameters/etc) using SàT XML""" + + def __init__(self, panel_type="window", container="vertical", dialog_opt=None, + title=None, submit_id=None, session_id=None): + """Init SàT XML Panel + + @param panel_type: one of + - C.XMLUI_WINDOW (new window) + - C.XMLUI_POPUP + - C.XMLUI_FORM (form, depend of the frontend, usually a panel with + cancel/submit buttons) + - C.XMLUI_PARAM (parameters, presentation depend of the frontend) + - C.XMLUI_DIALOG (one common dialog, presentation depend of frontend) + @param container: disposition of elements, one of: + - vertical: elements are disposed up to bottom + - horizontal: elements are disposed left to right + - pairs: elements come on two aligned columns + (usually one for a label, the next for the element) + - label: associations of one LabelWidget or EmptyWidget with an other widget + similar to pairs but specialized in LabelWidget, + and not necessarily arranged in 2 columns + - tabs: elemens are in categories with tabs (notebook) + @param dialog_opt: only used if panel_type == C.XMLUI_DIALOG. + Dictionnary (string/string) where key can be: + - C.XMLUI_DATA_TYPE: type of dialog, value can be: + - C.XMLUI_DIALOG_MESSAGE (default): an information/error message. + Action of user is necessary to close the dialog. + Usually the frontend display a classic popup. + - C.XMLUI_DIALOG_NOTE: like a C.XMLUI_DIALOG_MESSAGE, but action of user + is not necessary to close, at frontend choice (it can be closed after + a timeout). Usually the frontend display as a timed out notification + - C.XMLUI_DIALOG_CONFIRM: dialog with 2 choices (usualy "Ok"/"Cancel"). + returned data can contain: + - "answer": "true" if answer is "ok", "yes" or equivalent, + "false" else + - C.XLMUI_DIALOG_FILE: a file selection dialog + returned data can contain: + - "cancelled": "true" if dialog has been cancelled, not present + or "false" else + - "path": path of the choosed file/dir + - C.XMLUI_DATA_MESS: message shown in dialog + - C.XMLUI_DATA_LVL: one of: + - C.XMLUI_DATA_LVL_INFO (default): normal message + - C.XMLUI_DATA_LVL_WARNING: attention of user is important + - C.XMLUI_DATA_LVL_ERROR: something went wrong + - C.XMLUI_DATA_BTNS_SET: one of: + - C.XMLUI_DATA_BTNS_SET_OKCANCEL (default): classical "OK" and "Cancel" + set + - C.XMLUI_DATA_BTNS_SET_YESNO: a translated "yes" for OK, and "no" for + Cancel + - C.XMLUI_DATA_FILETYPE: only used for file dialogs, one of: + - C.XMLUI_DATA_FILETYPE_FILE: a file path is requested + - C.XMLUI_DATA_FILETYPE_DIR: a dir path is requested + - C.XMLUI_DATA_FILETYPE_DEFAULT: same as C.XMLUI_DATA_FILETYPE_FILE + + @param title: title or default if None + @param submit_id: callback id to call for panel_type we can submit (form, param, + dialog) + @param session_id: use to keep a session attached to the dialog, must be + returned by frontends + @attribute named_widgets(dict): map from name to widget + """ + if panel_type not in [ + C.XMLUI_WINDOW, + C.XMLUI_FORM, + C.XMLUI_PARAM, + C.XMLUI_POPUP, + C.XMLUI_DIALOG, + ]: + raise exceptions.DataError(_("Unknown panel type [%s]") % panel_type) + if panel_type == C.XMLUI_FORM and submit_id is None: + raise exceptions.DataError(_("form XMLUI need a submit_id")) + if not isinstance(container, str): + raise exceptions.DataError(_("container argument must be a string")) + if dialog_opt is not None and panel_type != C.XMLUI_DIALOG: + raise exceptions.DataError( + _("dialog_opt can only be used with dialog panels") + ) + self.type = panel_type + impl = minidom.getDOMImplementation() + + self.doc = impl.createDocument(None, "sat_xmlui", None) + top_element = self.doc.documentElement + top_element.setAttribute("type", panel_type) + if title: + top_element.setAttribute("title", title) + self.submit_id = submit_id + self.session_id = session_id + if panel_type == C.XMLUI_DIALOG: + if dialog_opt is None: + dialog_opt = {} + self._create_dialog(dialog_opt) + return + self.main_container = self._create_container(container, TopElement(self)) + self.current_container = self.main_container + self.named_widgets = {} + + @staticmethod + def creator_wrapper(widget_cls, is_input): + # TODO: once moved to Python 3, use functools.partialmethod and + # remove the creator_wrapper + def create_widget(self, *args, **kwargs): + if self.type == C.XMLUI_DIALOG: + raise exceptions.InternalError(_( + "create_widget can't be used with dialogs")) + if "parent" not in kwargs: + kwargs["parent"] = self.current_container + if "name" not in kwargs and is_input: + # name can be given as first argument or in keyword + # arguments for InputWidgets + args = list(args) + kwargs["name"] = args.pop(0) + return widget_cls(self, *args, **kwargs) + return create_widget + + @classmethod + def _introspect(cls): + """ Introspect module to find Widgets and Containers, and create addXXX methods""" + # FIXME: we can't log anything because this file is used + # in bin/sat script then evaluated + # bin/sat should be refactored + # log.debug(u'introspecting XMLUI widgets and containers') + cls._containers = {} + cls._widgets = {} + for obj in list(globals().values()): + try: + if issubclass(obj, Widget): + if obj.__name__ == "Widget": + continue + cls._widgets[obj.type] = obj + creator_name = "add" + obj.__name__ + if creator_name.endswith('Widget'): + creator_name = creator_name[:-6] + is_input = issubclass(obj, InputWidget) + # FIXME: cf. above comment + # log.debug(u"Adding {creator_name} creator (is_input={is_input}))" + # .format(creator_name=creator_name, is_input=is_input)) + + assert not hasattr(cls, creator_name) + # XXX: we need to use creator_wrapper because we are in a loop + # and Python 2 doesn't support default values in kwargs + # when using *args, **kwargs + setattr(cls, creator_name, cls.creator_wrapper(obj, is_input)) + + elif issubclass(obj, Container): + if obj.__name__ == "Container": + continue + cls._containers[obj.type] = obj + except TypeError: + pass + + def __del__(self): + self.doc.unlink() + + @property + def submit_id(self): + top_element = self.doc.documentElement + if not top_element.hasAttribute("submit"): + # getAttribute never return None (it return empty string it attribute doesn't exists) + # so we have to manage None here + return None + value = top_element.getAttribute("submit") + return value + + @submit_id.setter + def submit_id(self, value): + top_element = self.doc.documentElement + if value is None: + try: + top_element.removeAttribute("submit") + except NotFoundErr: + pass + else: # submit_id can be the empty string to bypass form restriction + top_element.setAttribute("submit", value) + + @property + def session_id(self): + top_element = self.doc.documentElement + value = top_element.getAttribute("session_id") + return value or None + + @session_id.setter + def session_id(self, value): + top_element = self.doc.documentElement + if value is None: + try: + top_element.removeAttribute("session_id") + except NotFoundErr: + pass + elif value: + top_element.setAttribute("session_id", value) + else: + raise exceptions.DataError("session_id can't be empty") + + def _create_dialog(self, dialog_opt): + dialog_type = dialog_opt.setdefault(C.XMLUI_DATA_TYPE, C.XMLUI_DIALOG_MESSAGE) + if ( + dialog_type in [C.XMLUI_DIALOG_CONFIRM, C.XMLUI_DIALOG_FILE] + and self.submit_id is None + ): + raise exceptions.InternalError( + _("Submit ID must be filled for this kind of dialog") + ) + top_element = TopElement(self) + level = dialog_opt.get(C.XMLUI_DATA_LVL) + dialog_elt = DialogElement(top_element, dialog_type, level) + + try: + MessageElement(dialog_elt, dialog_opt[C.XMLUI_DATA_MESS]) + except KeyError: + pass + + try: + ButtonsElement(dialog_elt, dialog_opt[C.XMLUI_DATA_BTNS_SET]) + except KeyError: + pass + + try: + FileElement(dialog_elt, dialog_opt[C.XMLUI_DATA_FILETYPE]) + except KeyError: + pass + + def _create_container(self, container, parent=None, **kwargs): + """Create a container element + + @param type: container type (cf init doc) + @parent: parent element or None + """ + if container not in self._containers: + raise exceptions.DataError(_("Unknown container type [%s]") % container) + cls = self._containers[container] + new_container = cls(self, parent=parent, **kwargs) + return new_container + + def change_container(self, container, **kwargs): + """Change the current container + + @param container: either container type (container it then created), + or an Container instance""" + if isinstance(container, str): + self.current_container = self._create_container( + container, + self.current_container.get_parent_container() or self.main_container, + **kwargs + ) + else: + self.current_container = ( + self.main_container if container is None else container + ) + assert isinstance(self.current_container, Container) + return self.current_container + + def add_widget(self, type_, *args, **kwargs): + """Convenience method to add an element""" + if "parent" not in kwargs: + kwargs["parent"] = self.current_container + try: + cls = self._widgets[type_] + except KeyError: + raise exceptions.DataError(_("Invalid type [{type_}]").format(type_=type_)) + return cls(self, *args, **kwargs) + + def toXml(self): + """return the XML representation of the panel""" + return self.doc.toxml() + + +# we call this to have automatic discovery of containers and widgets +XMLUI._introspect() + + +# Some sugar for XMLUI dialogs + + +def note(message, title="", level=C.XMLUI_DATA_LVL_INFO): + """sugar to easily create a Note Dialog + + @param message(unicode): body of the note + @param title(unicode): title of the note + @param level(unicode): one of C.XMLUI_DATA_LVL_* + @return(XMLUI): instance of XMLUI + """ + note_xmlui = XMLUI( + C.XMLUI_DIALOG, + dialog_opt={ + C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE, + C.XMLUI_DATA_MESS: message, + C.XMLUI_DATA_LVL: level, + }, + title=title, + ) + return note_xmlui + + +def quick_note(host, client, message, title="", level=C.XMLUI_DATA_LVL_INFO): + """more sugar to do the whole note process""" + note_ui = note(message, title, level) + host.action_new({"xmlui": note_ui.toXml()}, profile=client.profile) + + +def deferred_ui(host, xmlui, chained=False): + """create a deferred linked to XMLUI + + @param xmlui(XMLUI): instance of the XMLUI + Must be an XMLUI that you can submit, with submit_id set to '' + @param chained(bool): True if the Deferred result must be returned to the frontend + useful when backend is in a series of dialogs with an ui + @return (D(data)): a deferred which fire the data + """ + assert xmlui.submit_id == "" + xmlui_d = defer.Deferred() + + def on_submit(data, profile): + xmlui_d.callback(data) + return xmlui_d if chained else {} + + xmlui.submit_id = host.register_callback(on_submit, with_data=True, one_shot=True) + return xmlui_d + + +def defer_xmlui(host, xmlui, action_extra=None, security_limit=C.NO_SECURITY_LIMIT, + chained=False, profile=C.PROF_KEY_NONE): + """Create a deferred linked to XMLUI + + @param xmlui(XMLUI): instance of the XMLUI + Must be an XMLUI that you can submit, with submit_id set to '' + @param profile: %(doc_profile)s + @param action_extra(None, dict): extra action to merge with xmlui + mainly used to add meta informations (see action_new doc) + @param security_limit: %(doc_security_limit)s + @param chained(bool): True if the Deferred result must be returned to the frontend + useful when backend is in a series of dialogs with an ui + @return (data): a deferred which fire the data + """ + xmlui_d = deferred_ui(host, xmlui, chained) + action_data = {"xmlui": xmlui.toXml()} + if action_extra is not None: + action_data.update(action_extra) + host.action_new( + action_data, + security_limit=security_limit, + keep_id=xmlui.submit_id, + profile=profile, + ) + return xmlui_d + + +def defer_dialog( + host, + message: str, + title: str = "Please confirm", + type_: str = C.XMLUI_DIALOG_CONFIRM, + options: Optional[dict] = None, + action_extra: Optional[dict] = None, + security_limit: int = C.NO_SECURITY_LIMIT, + chained: bool = False, + profile: str = C.PROF_KEY_NONE +) -> defer.Deferred: + """Create a submitable dialog and manage it with a deferred + + @param message: message to display + @param title: title of the dialog + @param type: dialog type (C.XMLUI_DIALOG_* or plugin specific string) + @param options: if not None, will be used to update (extend) dialog_opt arguments of + XMLUI + @param action_extra: extra action to merge with xmlui + mainly used to add meta informations (see action_new doc) + @param security_limit: %(doc_security_limit)s + @param chained: True if the Deferred result must be returned to the frontend + useful when backend is in a series of dialogs with an ui + @param profile: %(doc_profile)s + @return: answer dict + """ + assert profile is not None + dialog_opt = {"type": type_, "message": message} + if options is not None: + dialog_opt.update(options) + dialog = XMLUI(C.XMLUI_DIALOG, title=title, dialog_opt=dialog_opt, submit_id="") + return defer_xmlui(host, dialog, action_extra, security_limit, chained, profile) + + +def defer_confirm(*args, **kwargs): + """call defer_dialog and return a boolean instead of the whole data dict""" + d = defer_dialog(*args, **kwargs) + d.addCallback(lambda data: C.bool(data["answer"])) + return d + + +# Misc other funtions + +def element_copy( + element: domish.Element, + with_parent: bool = True, + with_children: bool = True +) -> domish.Element: + """Make a copy of a domish.Element + + The copy will have its own children list, so other elements + can be added as direct children without modifying orignal one. + Children are not deeply copied, so if an element is added to a child or grandchild, + it will also affect original element. + @param element: Element to clone + """ + new_elt = domish.Element( + (element.uri, element.name), + defaultUri = element.defaultUri, + attribs = element.attributes, + localPrefixes = element.localPrefixes) + if with_parent: + new_elt.parent = element.parent + if with_children: + new_elt.children = element.children[:] + return new_elt + + +def is_xhtml_field(field): + """Check if a data_form.Field is an XHTML one""" + return (field.fieldType is None and field.ext_type == "xml" and + field.value.uri == C.NS_XHTML) + + +class ElementParser: + """Callable class to parse XML string into Element""" + + # XXX: Found at http://stackoverflow.com/questions/2093400/how-to-create-twisted-words-xish-domish-element-entirely-from-raw-xml/2095942#2095942 + + def _escape_html(self, matchobj): + entity = matchobj.group(1) + if entity in XML_ENTITIES: + # we don't escape XML entities + return matchobj.group(0) + else: + try: + return chr(html.entities.name2codepoint[entity]) + except KeyError: + log.warning("removing unknown entity {}".format(entity)) + return "" + + def __call__(self, raw_xml, force_spaces=False, namespace=None): + """ + @param raw_xml(unicode): the raw XML + @param force_spaces (bool): if True, replace occurrences of '\n' and '\t' + with ' '. + @param namespace(unicode, None): if set, use this namespace for the wrapping + element + """ + # we need to wrap element in case + # there is not a unique one on the top + if namespace is not None: + raw_xml = "<div xmlns='{}'>{}</div>".format(namespace, raw_xml) + else: + raw_xml = "<div>{}</div>".format(raw_xml) + + # avoid ParserError on HTML escaped chars + raw_xml = html_entity_re.sub(self._escape_html, raw_xml) + + self.result = None + + def on_start(elem): + self.result = elem + + def on_end(): + pass + + def onElement(elem): + self.result.addChild(elem) + + parser = domish.elementStream() + parser.DocumentStartEvent = on_start + parser.ElementEvent = onElement + parser.DocumentEndEvent = on_end + tmp = domish.Element((None, "s")) + if force_spaces: + raw_xml = raw_xml.replace("\n", " ").replace("\t", " ") + tmp.addRawXml(raw_xml) + parser.parse(tmp.toXml().encode("utf-8")) + top_elt = self.result.firstChildElement() + # we now can check if there was a unique element on the top + # and remove our wrapping <div/> is this is the case + top_elt_children = list(top_elt.elements()) + if len(top_elt_children) == 1: + top_elt = top_elt_children[0] + return top_elt + + +parse = ElementParser() + + +# FIXME: this method is duplicated from frontends.tools.xmlui.get_text +def get_text(node): + """Get child text nodes of a domish.Element. + + @param node (domish.Element) + @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) + + +def find_all(elt, namespaces=None, names=None): + """Find child element at any depth matching criteria + + @param elt(domish.Element): top parent of the elements to find + @param names(iterable[unicode], basestring, None): names to match + None to accept every names + @param namespace(iterable[unicode], basestring, None): URIs to match + None to accept every namespaces + @return ((G)domish.Element): found elements + """ + if isinstance(namespaces, str): + namespaces = tuple((namespaces,)) + if isinstance(names, str): + names = tuple((names,)) + + for child in elt.elements(): + if ( + domish.IElement.providedBy(child) + and (not names or child.name in names) + and (not namespaces or child.uri in namespaces) + ): + yield child + for found in find_all(child, namespaces, names): + yield found + + +def find_ancestor( + elt, + name: str, + namespace: Optional[Union[str, Iterable[str]]] = None + ) -> domish.Element: + """Retrieve ancestor of an element + + @param elt: starting element, its parent will be checked recursively until the + required one if found + @param name: name of the element to find + @param namespace: namespace of the element to find + - None to find any element with that name + - a simple string to find the namespace + - several namespaces can be specified in an iterable, if an element with any of + this namespace and given name is found, it will match + + """ + if isinstance(namespace, str): + namespace = [namespace] + current = elt.parent + while True: + if current is None: + raise exceptions.NotFound( + f"Can't find any ancestor {name!r} (xmlns: {namespace!r})" + ) + if current.name == name and (namespace is None or current.uri in namespace): + return current + current = current.parent + + +def p_fmt_elt(elt, indent=0, defaultUri=""): + """Pretty format a domish.Element""" + strings = [] + for child in elt.children: + if domish.IElement.providedBy(child): + strings.append(p_fmt_elt(child, indent+2, defaultUri=elt.defaultUri)) + else: + strings.append(f"{(indent+2)*' '}{child!s}") + if elt.children: + nochild_elt = domish.Element( + (elt.uri, elt.name), elt.defaultUri, elt.attributes, elt.localPrefixes + ) + strings.insert(0, f"{indent*' '}{nochild_elt.toXml(defaultUri=defaultUri)[:-2]}>") + strings.append(f"{indent*' '}</{nochild_elt.name}>") + else: + strings.append(f"{indent*' '}{elt.toXml(defaultUri=defaultUri)}") + return '\n'.join(strings) + + +def pp_elt(elt): + """Pretty print a domish.Element""" + print(p_fmt_elt(elt)) + + +# ElementTree + +def et_get_namespace_and_name(et_elt: ET.Element) -> Tuple[Optional[str], str]: + """Retrieve element namespace and name from ElementTree element + + @param et_elt: ElementTree element + @return: namespace and name of the element + if not namespace if specified, None is returned + """ + name = et_elt.tag + if not name: + raise ValueError("no name set in ET element") + elif name[0] != "{": + return None, name + end_idx = name.find("}") + if end_idx == -1: + raise ValueError("Invalid ET name") + return name[1:end_idx], name[end_idx+1:] + + +def et_elt_2_domish_elt(et_elt: Union[ET.Element, etree.Element]) -> domish.Element: + """Convert ElementTree element to Twisted's domish.Element + + Note: this is a naive implementation, adapted to XMPP, and some content are ignored + (attributes namespaces, tail) + """ + namespace, name = et_get_namespace_and_name(et_elt) + elt = domish.Element((namespace, name), attribs=et_elt.attrib) + if et_elt.text: + elt.addContent(et_elt.text) + for child in et_elt: + elt.addChild(et_elt_2_domish_elt(child)) + return elt + + +@overload +def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[False]) -> ET.Element: + ... + +@overload +def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[True]) -> etree.Element: + ... + +@overload +def domish_elt_2_et_elt( + elt: domish.Element, lxml: bool +) -> Union[ET.Element, etree.Element]: + ... + +def domish_elt_2_et_elt(elt, lxml = False): + """Convert Twisted's domish.Element to ElementTree equivalent + + Note: this is a naive implementation, adapted to XMPP, and some text content may be + missing (content put after a tag, i.e. what would go to the "tail" attribute of ET + Element) + """ + tag = f"{{{elt.uri}}}{elt.name}" if elt.uri else elt.name + if lxml: + et_elt = etree.Element(tag, attr=elt.attributes) + else: + et_elt = ET.Element(tag, attrib=elt.attributes) + content = str(elt) + if content: + et_elt.text = str(elt) + for child in elt.elements(): + et_elt.append(domish_elt_2_et_elt(child, lxml=lxml)) + return et_elt + + +def domish_elt_2_et_elt2(element: domish.Element) -> ET.Element: + """ + WIP, originally from the OMEMO plugin + """ + + element_name = element.name + if element.uri is not None: + element_name = "{" + element.uri + "}" + element_name + + attrib: Dict[str, str] = {} + for qname, value in element.attributes.items(): + attribute_name = qname[1] if isinstance(qname, tuple) else qname + attribute_namespace = qname[0] if isinstance(qname, tuple) else None + if attribute_namespace is not None: + attribute_name = "{" + attribute_namespace + "}" + attribute_name + + attrib[attribute_name] = value + + result = ET.Element(element_name, attrib) + + last_child: Optional[ET.Element] = None + for child in element.children: + if isinstance(child, str): + if last_child is None: + result.text = child + else: + last_child.tail = child + else: + last_child = domish_elt_2_et_elt2(child) + result.append(last_child) + + return result