comparison 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
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # SAT: a jabber client
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19
20 from collections import OrderedDict
21 import html.entities
22 import re
23 from typing import Dict, Optional, Tuple, Union, Literal, overload, Iterable
24 from xml.dom import NotFoundErr, minidom
25 import xml.etree.ElementTree as ET
26 from lxml import etree
27
28 from twisted.internet import defer
29 from twisted.words.protocols.jabber import jid
30 from twisted.words.xish import domish
31 from wokkel import data_form
32
33 from libervia.backend.core import exceptions
34 from libervia.backend.core.constants import Const as C
35 from libervia.backend.core.i18n import _
36 from libervia.backend.core.log import getLogger
37
38
39 log = getLogger(__name__)
40
41 """This library help manage XML used in SàT (parameters, registration, etc)"""
42
43 SAT_FORM_PREFIX = "SAT_FORM_"
44 SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_" # used to have unique elements names
45 html_entity_re = re.compile(r"&([a-zA-Z]+?);")
46 XML_ENTITIES = ("quot", "amp", "apos", "lt", "gt")
47
48 # method to clean XHTML, receive raw unsecure XML or HTML, must return cleaned raw XHTML
49 # this method must be set during runtime
50 clean_xhtml = None
51
52 # TODO: move XMLUI stuff in a separate module
53 # TODO: rewrite this with lxml or ElementTree or domish.Element: it's complicated and difficult to maintain with current minidom implementation
54
55 # Helper functions
56
57
58 def _data_form_field_2_xmlui_data(field, read_only=False):
59 """Get data needed to create an XMLUI's Widget from Wokkel's data_form's Field.
60
61 The attribute field can be modified (if it's fixed and it has no value).
62 @param field (data_form.Field): a field with attributes "value", "fieldType",
63 "label" and "var"
64 @param read_only (bool): if True and it makes sense, create a read only input widget
65 @return: a tuple (widget_type, widget_args, widget_kwargs)
66 """
67 widget_args = field.values or [None]
68 widget_kwargs = {}
69 if field.fieldType is None and field.ext_type is not None:
70 # we have an extended field
71 if field.ext_type == "xml":
72 element = field.value
73 if element.uri == C.NS_XHTML:
74 widget_type = "xhtmlbox"
75 widget_args[0] = element.toXml()
76 widget_kwargs["read_only"] = read_only
77 else:
78 log.warning("unknown XML element, falling back to textbox")
79 widget_type = "textbox"
80 widget_args[0] = element.toXml()
81 widget_kwargs["read_only"] = read_only
82 else:
83
84 raise exceptions.DataError("unknown extended type {ext_type}".format(
85 ext_type = field.ext_type))
86
87 elif field.fieldType == "fixed" or field.fieldType is None:
88 widget_type = "text"
89 if field.value is None:
90 if field.label is None:
91 log.warning(_("Fixed field has neither value nor label, ignoring it"))
92 field.value = ""
93 else:
94 field.value = field.label
95 field.label = None
96 widget_args = [field.value]
97 elif field.fieldType == "text-single":
98 widget_type = "string"
99 widget_kwargs["read_only"] = read_only
100 elif field.fieldType == "jid-single":
101 widget_type = "jid_input"
102 widget_kwargs["read_only"] = read_only
103 elif field.fieldType == "text-multi":
104 widget_type = "textbox"
105 widget_args = ["\n".join(field.values)]
106 widget_kwargs["read_only"] = read_only
107 elif field.fieldType == "hidden":
108 widget_type = "hidden"
109 elif field.fieldType == "text-private":
110 widget_type = "password"
111 widget_kwargs["read_only"] = read_only
112 elif field.fieldType == "boolean":
113 widget_type = "bool"
114 if widget_args[0] is None:
115 widget_args = ["false"]
116 widget_kwargs["read_only"] = read_only
117 elif field.fieldType == "integer":
118 widget_type = "integer"
119 widget_kwargs["read_only"] = read_only
120 elif field.fieldType == "list-single":
121 widget_type = "list"
122 widget_kwargs["options"] = [
123 (option.value, option.label or option.value) for option in field.options
124 ]
125 widget_kwargs["selected"] = widget_args
126 widget_args = []
127 elif field.fieldType == "list-multi":
128 widget_type = "list"
129 widget_kwargs["options"] = [
130 (option.value, option.label or option.value) for option in field.options
131 ]
132 widget_kwargs["selected"] = widget_args
133 widget_kwargs["styles"] = ["multi"]
134 widget_args = []
135 else:
136 log.error(
137 "FIXME FIXME FIXME: Type [%s] is not managed yet by SàT" % field.fieldType
138 )
139 widget_type = "string"
140 widget_kwargs["read_only"] = read_only
141
142 if field.var:
143 widget_kwargs["name"] = field.var
144
145 return widget_type, widget_args, widget_kwargs
146
147 def data_form_2_widgets(form_ui, form, read_only=False, prepend=None, filters=None):
148 """Complete an existing XMLUI with widget converted from XEP-0004 data forms.
149
150 @param form_ui (XMLUI): XMLUI instance
151 @param form (data_form.Form): Wokkel's implementation of data form
152 @param read_only (bool): if True and it makes sense, create a read only input widget
153 @param prepend(iterable, None): widgets to prepend to main LabelContainer
154 if not None, must be an iterable of *args for add_widget. Those widgets will
155 be added first to the container.
156 @param filters(dict, None): if not None, a dictionary of callable:
157 key is the name of the widget to filter
158 the value is a callable, it will get form's XMLUI, widget's type, args and kwargs
159 and must return widget's type, args and kwargs (which can be modified)
160 This is especially useful to modify well known fields
161 @return: the completed XMLUI instance
162 """
163 if filters is None:
164 filters = {}
165 if form.instructions:
166 form_ui.addText("\n".join(form.instructions), "instructions")
167
168 form_ui.change_container("label")
169
170 if prepend is not None:
171 for widget_args in prepend:
172 form_ui.add_widget(*widget_args)
173
174 for field in form.fieldList:
175 widget_type, widget_args, widget_kwargs = _data_form_field_2_xmlui_data(
176 field, read_only
177 )
178 try:
179 widget_filter = filters[widget_kwargs["name"]]
180 except KeyError:
181 pass
182 else:
183 widget_type, widget_args, widget_kwargs = widget_filter(
184 form_ui, widget_type, widget_args, widget_kwargs
185 )
186 if widget_type != "hidden":
187 label = field.label or field.var
188 if label:
189 form_ui.addLabel(label)
190 else:
191 form_ui.addEmpty()
192
193 form_ui.add_widget(widget_type, *widget_args, **widget_kwargs)
194
195 return form_ui
196
197
198 def data_form_2_xmlui(form, submit_id, session_id=None, read_only=False):
199 """Take a data form (Wokkel's XEP-0004 implementation) and convert it to a SàT XMLUI.
200
201 @param form (data_form.Form): a Form instance
202 @param submit_id (unicode): callback id to call when submitting form
203 @param session_id (unicode): session id to return with the data
204 @param read_only (bool): if True and it makes sense, create a read only input widget
205 @return: XMLUI instance
206 """
207 form_ui = XMLUI("form", "vertical", submit_id=submit_id, session_id=session_id)
208 return data_form_2_widgets(form_ui, form, read_only=read_only)
209
210
211 def data_form_2_data_dict(form: data_form.Form) -> dict:
212 """Convert data form to a simple dict, easily serialisable
213
214 see data_dict_2_data_form for a description of the format
215 """
216 fields = []
217 data_dict = {
218 "fields": fields
219 }
220 if form.formNamespace:
221 data_dict["namespace"] = form.formNamespace
222 for form_field in form.fieldList:
223 field = {"type": form_field.fieldType}
224 fields.append(field)
225 for src_name, dest_name in (
226 ('var', 'name'),
227 ('label', 'label'),
228 ('value', 'value'),
229 # FIXME: we probably should have only "values"
230 ('values', 'values')
231 ):
232 value = getattr(form_field, src_name, None)
233 if value:
234 field[dest_name] = value
235 if form_field.options:
236 options = field["options"] = []
237 for form_opt in form_field.options:
238 opt = {"value": form_opt.value}
239 if form_opt.label:
240 opt["label"] = form_opt.label
241 options.append(opt)
242
243 if form_field.fieldType is None and form_field.ext_type == "xml":
244 if isinstance(form_field.value, domish.Element):
245 if ((form_field.value.uri == C.NS_XHTML
246 and form_field.value.name == "div")):
247 field["type"] = "xhtml"
248 if form_field.value.children:
249 log.warning(
250 "children are not managed for XHTML fields: "
251 f"{form_field.value.toXml()}"
252 )
253 return data_dict
254
255
256 def data_dict_2_data_form(data_dict):
257 """Convert serialisable dict of data to a data form
258
259 The format of the dict is as follow:
260 - an optional "namespace" key with form namespace
261 - a mandatory "fields" key with list of fields as follow:
262 - "type" is mostly the same as data_form.Field.fieldType
263 - "name" is used to set the "var" attribute of data_form.Field
264 - "label", and "value" follow same attribude in data_form.Field
265 - "xhtml" is used for "xml" fields with child in the C.NS_XHTML namespace
266 - "options" are list of dict with optional "label" and mandatory "value"
267 following suitable attributes from data_form.Option
268 - "required" is the same as data_form.Field.required
269 """
270 # TODO: describe format
271 fields = []
272 for field_data in data_dict["fields"]:
273 field_type = field_data.get('type', 'text-single')
274 kwargs = {
275 "fieldType": field_type,
276 "var": field_data["name"],
277 "label": field_data.get('label'),
278 "value": field_data.get("value"),
279 "required": field_data.get("required")
280 }
281 if field_type == "xhtml":
282 kwargs.update({
283 "fieldType": None,
284 "ext_type": "xml",
285 })
286 if kwargs["value"] is None:
287 kwargs["value"] = domish.Element((C.NS_XHTML, "div"))
288 elif "options" in field_data:
289 kwargs["options"] = [
290 data_form.Option(o["value"], o.get("label"))
291 for o in field_data["options"]
292 ]
293 field = data_form.Field(**kwargs)
294 fields.append(field)
295 return data_form.Form(
296 "form",
297 formNamespace=data_dict.get("namespace"),
298 fields=fields
299 )
300
301
302 def data_form_elt_result_2_xmlui_data(form_xml):
303 """Parse a data form result (not parsed by Wokkel's XEP-0004 implementation).
304
305 The raw data form is used because Wokkel doesn't manage result items parsing yet.
306 @param form_xml (domish.Element): element of the data form
307 @return: a couple (headers, result_list):
308 - headers (dict{unicode: unicode}): form headers (field labels and types)
309 - xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs)
310 """
311 headers = OrderedDict()
312 try:
313 reported_elt = next(form_xml.elements("jabber:x:data", "reported"))
314 except StopIteration:
315 raise exceptions.DataError(
316 "Couldn't find expected <reported> tag in %s" % form_xml.toXml()
317 )
318
319 for elt in reported_elt.elements():
320 if elt.name != "field":
321 raise exceptions.DataError("Unexpected tag")
322 name = elt["var"]
323 label = elt.attributes.get("label", "")
324 type_ = elt.attributes.get("type")
325 headers[name] = (label, type_)
326
327 if not headers:
328 raise exceptions.DataError("No reported fields (see XEP-0004 §3.4)")
329
330 xmlui_data = []
331 item_elts = form_xml.elements("jabber:x:data", "item")
332
333 for item_elt in item_elts:
334 for elt in item_elt.elements():
335 if elt.name != "field":
336 log.warning("Unexpected tag (%s)" % elt.name)
337 continue
338 field = data_form.Field.fromElement(elt)
339
340 xmlui_data.append(_data_form_field_2_xmlui_data(field))
341
342 return headers, xmlui_data
343
344
345 def xmlui_data_2_advanced_list(xmlui, headers, xmlui_data):
346 """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list.
347
348 The raw data form is used because Wokkel doesn't manage result items parsing yet.
349 @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added
350 @param headers (dict{unicode: unicode}): form headers (field labels and types)
351 @param xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs)
352 @return: the completed XMLUI instance
353 """
354 adv_list = AdvancedListContainer(
355 xmlui, headers=headers, columns=len(headers), parent=xmlui.current_container
356 )
357 xmlui.change_container(adv_list)
358
359 for widget_type, widget_args, widget_kwargs in xmlui_data:
360 xmlui.add_widget(widget_type, *widget_args, **widget_kwargs)
361
362 return xmlui
363
364
365 def data_form_result_2_advanced_list(xmlui, form_xml):
366 """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list.
367
368 The raw data form is used because Wokkel doesn't manage result items parsing yet.
369 @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added
370 @param form_xml (domish.Element): element of the data form
371 @return: the completed XMLUI instance
372 """
373 headers, xmlui_data = data_form_elt_result_2_xmlui_data(form_xml)
374 xmlui_data_2_advanced_list(xmlui, headers, xmlui_data)
375
376
377 def data_form_elt_result_2_xmlui(form_elt, session_id=None):
378 """Take a raw data form (not parsed by XEP-0004) and convert it to a SàT XMLUI.
379
380 The raw data form is used because Wokkel doesn't manage result items parsing yet.
381 @param form_elt (domish.Element): element of the data form
382 @param session_id (unicode): session id to return with the data
383 @return: XMLUI instance
384 """
385 xml_ui = XMLUI("window", "vertical", session_id=session_id)
386 try:
387 data_form_result_2_advanced_list(xml_ui, form_elt)
388 except exceptions.DataError:
389 parsed_form = data_form.Form.fromElement(form_elt)
390 data_form_2_widgets(xml_ui, parsed_form, read_only=True)
391 return xml_ui
392
393
394 def data_form_result_2_xmlui(result_form, base_form, session_id=None, prepend=None,
395 filters=None, read_only=True):
396 """Convert data form result to SàT XMLUI.
397
398 @param result_form (data_form.Form): result form to convert
399 @param base_form (data_form.Form): initial form (i.e. of form type "form")
400 this one is necessary to reconstruct options when needed (e.g. list elements)
401 @param session_id (unicode): session id to return with the data
402 @param prepend: same as for [data_form_2_widgets]
403 @param filters: same as for [data_form_2_widgets]
404 @param read_only: same as for [data_form_2_widgets]
405 @return: XMLUI instance
406 """
407 # we deepcopy the form because _data_form_field_2_xmlui_data can modify the value
408 # FIXME: check if it's really important, the only modified value seems to be
409 # the replacement of None by "" on fixed fields
410 # form = deepcopy(result_form)
411 form = result_form
412 for name, field in form.fields.items():
413 try:
414 base_field = base_form.fields[name]
415 except KeyError:
416 continue
417 field.options = base_field.options[:]
418 xml_ui = XMLUI("window", "vertical", session_id=session_id)
419 data_form_2_widgets(xml_ui, form, read_only=read_only, prepend=prepend, filters=filters)
420 return xml_ui
421
422
423 def _clean_value(value):
424 """Workaround method to avoid DBus types with D-Bus bridge.
425
426 @param value: value to clean
427 @return: value in a non DBus type (only clean string yet)
428 """
429 # XXX: must be removed when DBus types will no cause problems anymore
430 # FIXME: should be cleaned inside D-Bus bridge itself
431 if isinstance(value, str):
432 return str(value)
433 return value
434
435
436 def xmlui_result_2_data_form_result(xmlui_data):
437 """ Extract form data from a XMLUI return.
438
439 @param xmlui_data (dict): data returned by frontends for XMLUI form
440 @return: dict of data usable by Wokkel's data form
441 """
442 ret = {}
443 for key, value in xmlui_data.items():
444 if not key.startswith(SAT_FORM_PREFIX):
445 continue
446 if isinstance(value, str):
447 if "\n" in value:
448 # data form expects multi-lines text to be in separated values
449 value = value.split('\n')
450 elif "\t" in value:
451 # FIXME: workaround to handle multiple values. Proper serialisation must
452 # be done in XMLUI
453 value = value.split("\t")
454 ret[key[len(SAT_FORM_PREFIX) :]] = _clean_value(value)
455 return ret
456
457
458 def form_escape(name):
459 """Return escaped name for forms.
460
461 @param name (unicode): form name
462 @return: unicode
463 """
464 return "%s%s" % (SAT_FORM_PREFIX, name)
465
466
467 def is_xmlui_cancelled(raw_xmlui):
468 """Tell if an XMLUI has been cancelled by checking raw XML"""
469 return C.bool(raw_xmlui.get('cancelled', C.BOOL_FALSE))
470
471
472 def xmlui_result_to_elt(xmlui_data):
473 """Construct result domish.Element from XMLUI result.
474
475 @param xmlui_data (dict): data returned by frontends for XMLUI form
476 @return: domish.Element
477 """
478 form = data_form.Form("submit")
479 form.makeFields(xmlui_result_2_data_form_result(xmlui_data))
480 return form.toElement()
481
482
483 def tuple_list_2_data_form(values):
484 """Convert a list of tuples (name, value) to a wokkel submit data form.
485
486 @param values (list): list of tuples
487 @return: data_form.Form
488 """
489 form = data_form.Form("submit")
490 for value in values:
491 field = data_form.Field(var=value[0], value=value[1])
492 form.addField(field)
493
494 return form
495
496
497 def params_xml_2_xmlui(xml):
498 """Convert the XML for parameter to a SàT XML User Interface.
499
500 @param xml (unicode)
501 @return: XMLUI
502 """
503 # TODO: refactor params and use Twisted directly to parse XML
504 params_doc = minidom.parseString(xml.encode("utf-8"))
505 top = params_doc.documentElement
506 if top.nodeName != "params":
507 raise exceptions.DataError(_("INTERNAL ERROR: parameters xml not valid"))
508
509 param_ui = XMLUI("param", "tabs")
510 tabs_cont = param_ui.current_container
511
512 for category in top.getElementsByTagName("category"):
513 category_name = category.getAttribute("name")
514 label = category.getAttribute("label")
515 if not category_name:
516 raise exceptions.DataError(
517 _("INTERNAL ERROR: params categories must have a name")
518 )
519 tabs_cont.add_tab(category_name, label=label, container=LabelContainer)
520 for param in category.getElementsByTagName("param"):
521 widget_kwargs = {}
522
523 param_name = param.getAttribute("name")
524 param_label = param.getAttribute("label")
525 type_ = param.getAttribute("type")
526 if not param_name and type_ != "text":
527 raise exceptions.DataError(_("INTERNAL ERROR: params must have a name"))
528
529 value = param.getAttribute("value") or None
530 callback_id = param.getAttribute("callback_id") or None
531
532 if type_ == "list":
533 options, selected = _params_get_list_options(param)
534 widget_kwargs["options"] = options
535 widget_kwargs["selected"] = selected
536 widget_kwargs["styles"] = ["extensible"]
537 elif type_ == "jids_list":
538 widget_kwargs["jids"] = _params_get_list_jids(param)
539
540 if type_ in ("button", "text"):
541 param_ui.addEmpty()
542 value = param_label
543 else:
544 param_ui.addLabel(param_label or param_name)
545
546 if value:
547 widget_kwargs["value"] = value
548
549 if callback_id:
550 widget_kwargs["callback_id"] = callback_id
551 others = [
552 "%s%s%s"
553 % (category_name, SAT_PARAM_SEPARATOR, other.getAttribute("name"))
554 for other in category.getElementsByTagName("param")
555 if other.getAttribute("type") != "button"
556 ]
557 widget_kwargs["fields_back"] = others
558
559 widget_kwargs["name"] = "%s%s%s" % (
560 category_name,
561 SAT_PARAM_SEPARATOR,
562 param_name,
563 )
564
565 param_ui.add_widget(type_, **widget_kwargs)
566
567 return param_ui.toXml()
568
569
570 def _params_get_list_options(param):
571 """Retrieve the options for list element.
572
573 The <option/> tags must be direct children of <param/>.
574 @param param (domish.Element): element
575 @return: a tuple (options, selected_value)
576 """
577 if len(param.getElementsByTagName("options")) > 0:
578 raise exceptions.DataError(
579 _("The 'options' tag is not allowed in parameter of type 'list'!")
580 )
581 elems = param.getElementsByTagName("option")
582 if len(elems) == 0:
583 return []
584 options = []
585 for elem in elems:
586 value = elem.getAttribute("value")
587 if not value:
588 raise exceptions.InternalError("list option must have a value")
589 label = elem.getAttribute("label")
590 if label:
591 options.append((value, label))
592 else:
593 options.append(value)
594 selected = [
595 elem.getAttribute("value")
596 for elem in elems
597 if elem.getAttribute("selected") == "true"
598 ]
599 return (options, selected)
600
601
602 def _params_get_list_jids(param):
603 """Retrive jids from a jids_list element.
604
605 the <jid/> tags must be direct children of <param/>
606 @param param (domish.Element): element
607 @return: a list of jids
608 """
609 elems = param.getElementsByTagName("jid")
610 jids = [
611 elem.firstChild.data
612 for elem in elems
613 if elem.firstChild is not None and elem.firstChild.nodeType == elem.TEXT_NODE
614 ]
615 return jids
616
617
618 ### XMLUI Elements ###
619
620
621 class Element(object):
622 """ Base XMLUI element """
623
624 type = None
625
626 def __init__(self, xmlui, parent=None):
627 """Create a container element
628
629 @param xmlui: XMLUI instance
630 @parent: parent element
631 """
632 assert self.type is not None
633 self.children = []
634 if not hasattr(self, "elem"):
635 self.elem = parent.xmlui.doc.createElement(self.type)
636 self.xmlui = xmlui
637 if parent is not None:
638 parent.append(self)
639 self.parent = parent
640
641 def append(self, child):
642 """Append a child to this element.
643
644 @param child (Element): child element
645 @return: the added child Element
646 """
647 self.elem.appendChild(child.elem)
648 child.parent = self
649 self.children.append(child)
650 return child
651
652
653 class TopElement(Element):
654 """ Main XML Element """
655
656 type = "top"
657
658 def __init__(self, xmlui):
659 self.elem = xmlui.doc.documentElement
660 super(TopElement, self).__init__(xmlui)
661
662
663 class TabElement(Element):
664 """ Used by TabsContainer to give name and label to tabs."""
665
666 type = "tab"
667
668 def __init__(self, parent, name, label, selected=False):
669 """
670
671 @param parent (TabsContainer): parent container
672 @param name (unicode): tab name
673 @param label (unicode): tab label
674 @param selected (bool): set to True to select this tab
675 """
676 if not isinstance(parent, TabsContainer):
677 raise exceptions.DataError(_("TabElement must be a child of TabsContainer"))
678 super(TabElement, self).__init__(parent.xmlui, parent)
679 self.elem.setAttribute("name", name)
680 self.elem.setAttribute("label", label)
681 if selected:
682 self.set_selected(selected)
683
684 def set_selected(self, selected=False):
685 """Set the tab selected.
686
687 @param selected (bool): set to True to select this tab
688 """
689 self.elem.setAttribute("selected", "true" if selected else "false")
690
691
692 class FieldBackElement(Element):
693 """ Used by ButtonWidget to indicate which field have to be sent back """
694
695 type = "field_back"
696
697 def __init__(self, parent, name):
698 assert isinstance(parent, ButtonWidget)
699 super(FieldBackElement, self).__init__(parent.xmlui, parent)
700 self.elem.setAttribute("name", name)
701
702
703 class InternalFieldElement(Element):
704 """ Used by internal callbacks to indicate which fields are manipulated """
705
706 type = "internal_field"
707
708 def __init__(self, parent, name):
709 super(InternalFieldElement, self).__init__(parent.xmlui, parent)
710 self.elem.setAttribute("name", name)
711
712
713 class InternalDataElement(Element):
714 """ Used by internal callbacks to retrieve extra data """
715
716 type = "internal_data"
717
718 def __init__(self, parent, children):
719 super(InternalDataElement, self).__init__(parent.xmlui, parent)
720 assert isinstance(children, list)
721 for child in children:
722 self.elem.childNodes.append(child)
723
724
725 class OptionElement(Element):
726 """" Used by ListWidget to specify options """
727
728 type = "option"
729
730 def __init__(self, parent, option, selected=False):
731 """
732
733 @param parent
734 @param option (string, tuple)
735 @param selected (boolean)
736 """
737 assert isinstance(parent, ListWidget)
738 super(OptionElement, self).__init__(parent.xmlui, parent)
739 if isinstance(option, str):
740 value, label = option, option
741 elif isinstance(option, tuple):
742 value, label = option
743 else:
744 raise NotImplementedError
745 self.elem.setAttribute("value", value)
746 self.elem.setAttribute("label", label)
747 if selected:
748 self.elem.setAttribute("selected", "true")
749
750
751 class JidElement(Element):
752 """" Used by JidsListWidget to specify jids"""
753
754 type = "jid"
755
756 def __init__(self, parent, jid_):
757 """
758 @param jid_(jid.JID, unicode): jid to append
759 """
760 assert isinstance(parent, JidsListWidget)
761 super(JidElement, self).__init__(parent.xmlui, parent)
762 if isinstance(jid_, jid.JID):
763 value = jid_.full()
764 elif isinstance(jid_, str):
765 value = str(jid_)
766 else:
767 raise NotImplementedError
768 jid_txt = self.xmlui.doc.createTextNode(value)
769 self.elem.appendChild(jid_txt)
770
771
772 class RowElement(Element):
773 """" Used by AdvancedListContainer """
774
775 type = "row"
776
777 def __init__(self, parent):
778 assert isinstance(parent, AdvancedListContainer)
779 super(RowElement, self).__init__(parent.xmlui, parent)
780 if parent.next_row_idx is not None:
781 if parent.auto_index:
782 raise exceptions.DataError(_("Can't set row index if auto_index is True"))
783 self.elem.setAttribute("index", parent.next_row_idx)
784 parent.next_row_idx = None
785
786
787 class HeaderElement(Element):
788 """" Used by AdvancedListContainer """
789
790 type = "header"
791
792 def __init__(self, parent, name=None, label=None, description=None):
793 """
794 @param parent: AdvancedListContainer instance
795 @param name: name of the container
796 @param label: label to be displayed in columns
797 @param description: long descriptive text
798 """
799 assert isinstance(parent, AdvancedListContainer)
800 super(HeaderElement, self).__init__(parent.xmlui, parent)
801 if name:
802 self.elem.setAttribute("name", name)
803 if label:
804 self.elem.setAttribute("label", label)
805 if description:
806 self.elem.setAttribute("description", description)
807
808
809 ## Containers ##
810
811
812 class Container(Element):
813 """ And Element which contains other ones and has a layout """
814
815 type = None
816
817 def __init__(self, xmlui, parent=None):
818 """Create a container element
819
820 @param xmlui: XMLUI instance
821 @parent: parent element or None
822 """
823 self.elem = xmlui.doc.createElement("container")
824 super(Container, self).__init__(xmlui, parent)
825 self.elem.setAttribute("type", self.type)
826
827 def get_parent_container(self):
828 """ Return first parent container
829
830 @return: parent container or None
831 """
832 current = self.parent
833 while not isinstance(current, (Container)) and current is not None:
834 current = current.parent
835 return current
836
837
838 class VerticalContainer(Container):
839 type = "vertical"
840
841
842 class HorizontalContainer(Container):
843 type = "horizontal"
844
845
846 class PairsContainer(Container):
847 """Container with series of 2 elements"""
848 type = "pairs"
849
850
851 class LabelContainer(Container):
852 """Like PairsContainer, but first element can only be a label"""
853 type = "label"
854
855
856 class TabsContainer(Container):
857 type = "tabs"
858
859 def add_tab(self, name, label=None, selected=None, container=VerticalContainer):
860 """Add a tab.
861
862 @param name (unicode): tab name
863 @param label (unicode): tab label
864 @param selected (bool): set to True to select this tab
865 @param container (class): container class, inheriting from Container
866 @return: the container for the new tab
867 """
868 if not label:
869 label = name
870 tab_elt = TabElement(self, name, label, selected)
871 new_container = container(self.xmlui, tab_elt)
872 return self.xmlui.change_container(new_container)
873
874 def end(self):
875 """ Called when we have finished tabs
876
877 change current container to first container parent
878 """
879 parent_container = self.get_parent_container()
880 self.xmlui.change_container(parent_container)
881
882
883 class AdvancedListContainer(Container):
884 """A list which can contain other widgets, headers, etc"""
885
886 type = "advanced_list"
887
888 def __init__(
889 self,
890 xmlui,
891 callback_id=None,
892 name=None,
893 headers=None,
894 items=None,
895 columns=None,
896 selectable="no",
897 auto_index=False,
898 parent=None,
899 ):
900 """Create an advanced list
901
902 @param headers: optional headers information
903 @param callback_id: id of the method to call when selection is done
904 @param items: list of widgets to add (just the first row)
905 @param columns: number of columns in this table, or None to autodetect
906 @param selectable: one of:
907 'no': nothing is done
908 'single': one row can be selected
909 @param auto_index: if True, indexes will be generated by frontends,
910 starting from 0
911 @return: created element
912 """
913 assert selectable in ("no", "single")
914 if not items and columns is None:
915 raise exceptions.DataError(_("either items or columns need do be filled"))
916 if headers is None:
917 headers = []
918 if items is None:
919 items = []
920 super(AdvancedListContainer, self).__init__(xmlui, parent)
921 if columns is None:
922 columns = len(items[0])
923 self._columns = columns
924 self._item_idx = 0
925 self.current_row = None
926 if headers:
927 if len(headers) != self._columns:
928 raise exceptions.DataError(
929 _("Headers lenght doesn't correspond to columns")
930 )
931 self.add_headers(headers)
932 if items:
933 self.add_items(items)
934 self.elem.setAttribute("columns", str(self._columns))
935 if callback_id is not None:
936 self.elem.setAttribute("callback", callback_id)
937 self.elem.setAttribute("selectable", selectable)
938 self.auto_index = auto_index
939 if auto_index:
940 self.elem.setAttribute("auto_index", "true")
941 self.next_row_idx = None
942
943 def add_headers(self, headers):
944 for header in headers:
945 self.addHeader(header)
946
947 def addHeader(self, header):
948 pass # TODO
949
950 def add_items(self, items):
951 for item in items:
952 self.append(item)
953
954 def set_row_index(self, idx):
955 """ Set index for next row
956
957 index are returned when a row is selected, in data's "index" key
958 @param idx: string index to associate to the next row
959 """
960 self.next_row_idx = idx
961
962 def append(self, child):
963 if isinstance(child, RowElement):
964 return super(AdvancedListContainer, self).append(child)
965 if self._item_idx % self._columns == 0:
966 self.current_row = RowElement(self)
967 self.current_row.append(child)
968 self._item_idx += 1
969
970 def end(self):
971 """ Called when we have finished list
972
973 change current container to first container parent
974 """
975 if self._item_idx % self._columns != 0:
976 raise exceptions.DataError(_("Incorrect number of items in list"))
977 parent_container = self.get_parent_container()
978 self.xmlui.change_container(parent_container)
979
980
981 ## Widgets ##
982
983
984 class Widget(Element):
985 type = None
986
987 def __init__(self, xmlui, name=None, parent=None):
988 """Create an element
989
990 @param xmlui: XMLUI instance
991 @param name: name of the element or None
992 @param parent: parent element or None
993 """
994 self.elem = xmlui.doc.createElement("widget")
995 super(Widget, self).__init__(xmlui, parent)
996 if name:
997 self.elem.setAttribute("name", name)
998 if name in xmlui.named_widgets:
999 raise exceptions.ConflictError(
1000 _('A widget with the name "{name}" already exists.').format(
1001 name=name
1002 )
1003 )
1004 xmlui.named_widgets[name] = self
1005 self.elem.setAttribute("type", self.type)
1006
1007 def set_internal_callback(self, callback, fields, data_elts=None):
1008 """Set an internal UI callback when the widget value is changed.
1009
1010 The internal callbacks are NO callback ids, they are strings from
1011 a predefined set of actions that are running in the scope of XMLUI.
1012 @param callback (string): a value from:
1013 - 'copy': process the widgets given in 'fields' two by two, by
1014 copying the values of one widget to the other. Target widgets
1015 of type List do not accept the empty value.
1016 - 'move': same than copy but moves the values if the source widget
1017 is not a List.
1018 - 'groups_of_contact': process the widgets two by two, assume A is
1019 is a list of JID and B a list of groups, select in B the groups
1020 to which the JID selected in A belongs.
1021 - more operation to be added when necessary...
1022 @param fields (list): a list of widget names (string)
1023 @param data_elts (list[Element]): extra data elements
1024 """
1025 self.elem.setAttribute("internal_callback", callback)
1026 if fields:
1027 for field in fields:
1028 InternalFieldElement(self, field)
1029 if data_elts:
1030 InternalDataElement(self, data_elts)
1031
1032
1033 class EmptyWidget(Widget):
1034 """Place holder widget"""
1035
1036 type = "empty"
1037
1038
1039 class TextWidget(Widget):
1040 """Used for blob of text"""
1041
1042 type = "text"
1043
1044 def __init__(self, xmlui, value, name=None, parent=None):
1045 super(TextWidget, self).__init__(xmlui, name, parent)
1046 value_elt = self.xmlui.doc.createElement("value")
1047 text = self.xmlui.doc.createTextNode(value)
1048 value_elt.appendChild(text)
1049 self.elem.appendChild(value_elt)
1050
1051 @property
1052 def value(self):
1053 return self.elem.firstChild.firstChild.wholeText
1054
1055
1056 class LabelWidget(Widget):
1057 """One line blob of text
1058
1059 used most of time to display the desciption or name of the next widget
1060 """
1061 type = "label"
1062
1063 def __init__(self, xmlui, label, name=None, parent=None):
1064 super(LabelWidget, self).__init__(xmlui, name, parent)
1065 self.elem.setAttribute("value", label)
1066
1067
1068 class HiddenWidget(Widget):
1069 """Not displayed widget, frontends will just copy the value(s)"""
1070 type = "hidden"
1071
1072 def __init__(self, xmlui, value, name, parent=None):
1073 super(HiddenWidget, self).__init__(xmlui, name, parent)
1074 value_elt = self.xmlui.doc.createElement("value")
1075 text = self.xmlui.doc.createTextNode(value)
1076 value_elt.appendChild(text)
1077 self.elem.appendChild(value_elt)
1078
1079 @property
1080 def value(self):
1081 return self.elem.firstChild.firstChild.wholeText
1082
1083
1084 class JidWidget(Widget):
1085 """Used to display a Jabber ID, some specific methods can be added"""
1086
1087 type = "jid"
1088
1089 def __init__(self, xmlui, jid, name=None, parent=None):
1090 super(JidWidget, self).__init__(xmlui, name, parent)
1091 try:
1092 self.elem.setAttribute("value", jid.full())
1093 except AttributeError:
1094 self.elem.setAttribute("value", str(jid))
1095
1096 @property
1097 def value(self):
1098 return self.elem.getAttribute("value")
1099
1100
1101 class DividerWidget(Widget):
1102 type = "divider"
1103
1104 def __init__(self, xmlui, style="line", name=None, parent=None):
1105 """ Create a divider
1106
1107 @param xmlui: XMLUI instance
1108 @param style: one of:
1109 - line: a simple line
1110 - dot: a line of dots
1111 - dash: a line of dashes
1112 - plain: a full thick line
1113 - blank: a blank line/space
1114 @param name: name of the widget
1115 @param parent: parent container
1116
1117 """
1118 super(DividerWidget, self).__init__(xmlui, name, parent)
1119 self.elem.setAttribute("style", style)
1120
1121
1122 ### Inputs ###
1123
1124
1125 class InputWidget(Widget):
1126 """Widget which can accept user inputs
1127
1128 used mainly in forms
1129 """
1130
1131 def __init__(self, xmlui, name=None, parent=None, read_only=False):
1132 super(InputWidget, self).__init__(xmlui, name, parent)
1133 if read_only:
1134 self.elem.setAttribute("read_only", "true")
1135
1136
1137 class StringWidget(InputWidget):
1138 type = "string"
1139
1140 def __init__(self, xmlui, value=None, name=None, parent=None, read_only=False):
1141 super(StringWidget, self).__init__(xmlui, name, parent, read_only=read_only)
1142 if value:
1143 value_elt = self.xmlui.doc.createElement("value")
1144 text = self.xmlui.doc.createTextNode(value)
1145 value_elt.appendChild(text)
1146 self.elem.appendChild(value_elt)
1147
1148 @property
1149 def value(self):
1150 return self.elem.firstChild.firstChild.wholeText
1151
1152
1153 class PasswordWidget(StringWidget):
1154 type = "password"
1155
1156
1157 class TextBoxWidget(StringWidget):
1158 type = "textbox"
1159
1160
1161 class XHTMLBoxWidget(StringWidget):
1162 """Specialized textbox to manipulate XHTML"""
1163 type = "xhtmlbox"
1164
1165 def __init__(self, xmlui, value, name=None, parent=None, read_only=False, clean=True):
1166 """
1167 @param clean(bool): if True, the XHTML is considered insecure and will be cleaned
1168 Only set to False if you are absolutely sure that the XHTML is safe (in other
1169 word, set to False only if you made the XHTML yourself)
1170 """
1171 if clean:
1172 if clean_xhtml is None:
1173 raise exceptions.NotFound(
1174 "No cleaning method set, can't clean the XHTML")
1175 value = clean_xhtml(value)
1176
1177 super(XHTMLBoxWidget, self).__init__(
1178 xmlui, value=value, name=name, parent=parent, read_only=read_only)
1179
1180
1181 class JidInputWidget(StringWidget):
1182 type = "jid_input"
1183
1184
1185 # TODO handle min and max values
1186 class IntWidget(StringWidget):
1187 type = "int"
1188
1189 def __init__(self, xmlui, value=0, name=None, parent=None, read_only=False):
1190 try:
1191 int(value)
1192 except ValueError:
1193 raise exceptions.DataError(_("Value must be an integer"))
1194 super(IntWidget, self).__init__(xmlui, value, name, parent, read_only=read_only)
1195
1196
1197 class BoolWidget(InputWidget):
1198 type = "bool"
1199
1200 def __init__(self, xmlui, value="false", name=None, parent=None, read_only=False):
1201 if isinstance(value, bool):
1202 value = "true" if value else "false"
1203 elif value == "0":
1204 value = "false"
1205 elif value == "1":
1206 value = "true"
1207 if value not in ("true", "false"):
1208 raise exceptions.DataError(_("Value must be 0, 1, false or true"))
1209 super(BoolWidget, self).__init__(xmlui, name, parent, read_only=read_only)
1210 self.elem.setAttribute("value", value)
1211
1212
1213 class ButtonWidget(Widget):
1214 type = "button"
1215
1216 def __init__(
1217 self, xmlui, callback_id, value=None, fields_back=None, name=None, parent=None
1218 ):
1219 """Add a button
1220
1221 @param callback_id: callback which will be called if button is pressed
1222 @param value: label of the button
1223 @param fields_back: list of names of field to give back when pushing the button
1224 @param name: name
1225 @param parent: parent container
1226 """
1227 if fields_back is None:
1228 fields_back = []
1229 super(ButtonWidget, self).__init__(xmlui, name, parent)
1230 self.elem.setAttribute("callback", callback_id)
1231 if value:
1232 self.elem.setAttribute("value", value)
1233 for field in fields_back:
1234 FieldBackElement(self, field)
1235
1236
1237 class ListWidget(InputWidget):
1238 type = "list"
1239 STYLES = ("multi", "noselect", "extensible", "reducible", "inline")
1240
1241 def __init__(
1242 self, xmlui, options, selected=None, styles=None, name=None, parent=None
1243 ):
1244 """
1245
1246 @param xmlui
1247 @param options (list[option]): each option can be given as:
1248 - a single string if the label and the value are the same
1249 - a tuple with a couple of string (value,label) if the label and the
1250 value differ
1251 @param selected (list[string]): list of the selected values
1252 @param styles (iterable[string]): flags to set the behaviour of the list
1253 can be:
1254 - multi: multiple selection is allowed
1255 - noselect: no selection is allowed
1256 useful when only the list itself is needed
1257 - extensible: can be extended by user (i.e. new options can be added)
1258 - reducible: can be reduced by user (i.e. options can be removed)
1259 - inline: hint that this list should be displayed on a single line
1260 (e.g. list of labels)
1261 @param name (string)
1262 @param parent
1263 """
1264 styles = set() if styles is None else set(styles)
1265 if styles is None:
1266 styles = set()
1267 else:
1268 styles = set(styles)
1269 if "noselect" in styles and ("multi" in styles or selected):
1270 raise exceptions.DataError(
1271 _(
1272 '"multi" flag and "selected" option are not compatible with '
1273 '"noselect" flag'
1274 )
1275 )
1276 if not options:
1277 # we can have no options if we get a submitted data form
1278 # but we can't use submitted values directly,
1279 # because we would not have the labels
1280 log.warning(_('empty "options" list'))
1281 super(ListWidget, self).__init__(xmlui, name, parent)
1282 self.add_options(options, selected)
1283 self.set_styles(styles)
1284
1285 def add_options(self, options, selected=None):
1286 """Add options to a multi-values element (e.g. list) """
1287 if selected:
1288 if isinstance(selected, str):
1289 selected = [selected]
1290 else:
1291 selected = []
1292 for option in options:
1293 assert isinstance(option, str) or isinstance(option, tuple)
1294 value = option if isinstance(option, str) else option[0]
1295 OptionElement(self, option, value in selected)
1296
1297 def set_styles(self, styles):
1298 if not styles.issubset(self.STYLES):
1299 raise exceptions.DataError(_("invalid styles"))
1300 for style in styles:
1301 self.elem.setAttribute(style, "yes")
1302 # TODO: check flags incompatibily (noselect and multi) like in __init__
1303
1304 def setStyle(self, style):
1305 self.set_styles([style])
1306
1307 @property
1308 def value(self):
1309 """Return the value of first selected option"""
1310 for child in self.elem.childNodes:
1311 if child.tagName == "option" and child.getAttribute("selected") == "true":
1312 return child.getAttribute("value")
1313 return ""
1314
1315
1316 class JidsListWidget(InputWidget):
1317 """A list of text or jids where elements can be added/removed or modified"""
1318
1319 type = "jids_list"
1320
1321 def __init__(self, xmlui, jids, styles=None, name=None, parent=None):
1322 """
1323
1324 @param xmlui
1325 @param jids (list[jid.JID]): base jids
1326 @param styles (iterable[string]): flags to set the behaviour of the list
1327 @param name (string)
1328 @param parent
1329 """
1330 super(JidsListWidget, self).__init__(xmlui, name, parent)
1331 styles = set() if styles is None else set(styles)
1332 if not styles.issubset([]): # TODO
1333 raise exceptions.DataError(_("invalid styles"))
1334 for style in styles:
1335 self.elem.setAttribute(style, "yes")
1336 if not jids:
1337 log.debug("empty jids list")
1338 else:
1339 self.add_jids(jids)
1340
1341 def add_jids(self, jids):
1342 for jid_ in jids:
1343 JidElement(self, jid_)
1344
1345
1346 ## Dialog Elements ##
1347
1348
1349 class DialogElement(Element):
1350 """Main dialog element """
1351
1352 type = "dialog"
1353
1354 def __init__(self, parent, type_, level=None):
1355 if not isinstance(parent, TopElement):
1356 raise exceptions.DataError(
1357 _("DialogElement must be a direct child of TopElement")
1358 )
1359 super(DialogElement, self).__init__(parent.xmlui, parent)
1360 self.elem.setAttribute(C.XMLUI_DATA_TYPE, type_)
1361 self.elem.setAttribute(C.XMLUI_DATA_LVL, level or C.XMLUI_DATA_LVL_DEFAULT)
1362
1363
1364 class MessageElement(Element):
1365 """Element with the instruction message"""
1366
1367 type = C.XMLUI_DATA_MESS
1368
1369 def __init__(self, parent, message):
1370 if not isinstance(parent, DialogElement):
1371 raise exceptions.DataError(
1372 _("MessageElement must be a direct child of DialogElement")
1373 )
1374 super(MessageElement, self).__init__(parent.xmlui, parent)
1375 message_txt = self.xmlui.doc.createTextNode(message)
1376 self.elem.appendChild(message_txt)
1377
1378
1379 class ButtonsElement(Element):
1380 """Buttons element which indicate which set to use"""
1381
1382 type = "buttons"
1383
1384 def __init__(self, parent, set_):
1385 if not isinstance(parent, DialogElement):
1386 raise exceptions.DataError(
1387 _("ButtonsElement must be a direct child of DialogElement")
1388 )
1389 super(ButtonsElement, self).__init__(parent.xmlui, parent)
1390 self.elem.setAttribute("set", set_)
1391
1392
1393 class FileElement(Element):
1394 """File element used for FileDialog"""
1395
1396 type = "file"
1397
1398 def __init__(self, parent, type_):
1399 if not isinstance(parent, DialogElement):
1400 raise exceptions.DataError(
1401 _("FileElement must be a direct child of DialogElement")
1402 )
1403 super(FileElement, self).__init__(parent.xmlui, parent)
1404 self.elem.setAttribute("type", type_)
1405
1406
1407 ## XMLUI main class
1408
1409
1410 class XMLUI(object):
1411 """This class is used to create a user interface (form/window/parameters/etc) using SàT XML"""
1412
1413 def __init__(self, panel_type="window", container="vertical", dialog_opt=None,
1414 title=None, submit_id=None, session_id=None):
1415 """Init SàT XML Panel
1416
1417 @param panel_type: one of
1418 - C.XMLUI_WINDOW (new window)
1419 - C.XMLUI_POPUP
1420 - C.XMLUI_FORM (form, depend of the frontend, usually a panel with
1421 cancel/submit buttons)
1422 - C.XMLUI_PARAM (parameters, presentation depend of the frontend)
1423 - C.XMLUI_DIALOG (one common dialog, presentation depend of frontend)
1424 @param container: disposition of elements, one of:
1425 - vertical: elements are disposed up to bottom
1426 - horizontal: elements are disposed left to right
1427 - pairs: elements come on two aligned columns
1428 (usually one for a label, the next for the element)
1429 - label: associations of one LabelWidget or EmptyWidget with an other widget
1430 similar to pairs but specialized in LabelWidget,
1431 and not necessarily arranged in 2 columns
1432 - tabs: elemens are in categories with tabs (notebook)
1433 @param dialog_opt: only used if panel_type == C.XMLUI_DIALOG.
1434 Dictionnary (string/string) where key can be:
1435 - C.XMLUI_DATA_TYPE: type of dialog, value can be:
1436 - C.XMLUI_DIALOG_MESSAGE (default): an information/error message.
1437 Action of user is necessary to close the dialog.
1438 Usually the frontend display a classic popup.
1439 - C.XMLUI_DIALOG_NOTE: like a C.XMLUI_DIALOG_MESSAGE, but action of user
1440 is not necessary to close, at frontend choice (it can be closed after
1441 a timeout). Usually the frontend display as a timed out notification
1442 - C.XMLUI_DIALOG_CONFIRM: dialog with 2 choices (usualy "Ok"/"Cancel").
1443 returned data can contain:
1444 - "answer": "true" if answer is "ok", "yes" or equivalent,
1445 "false" else
1446 - C.XLMUI_DIALOG_FILE: a file selection dialog
1447 returned data can contain:
1448 - "cancelled": "true" if dialog has been cancelled, not present
1449 or "false" else
1450 - "path": path of the choosed file/dir
1451 - C.XMLUI_DATA_MESS: message shown in dialog
1452 - C.XMLUI_DATA_LVL: one of:
1453 - C.XMLUI_DATA_LVL_INFO (default): normal message
1454 - C.XMLUI_DATA_LVL_WARNING: attention of user is important
1455 - C.XMLUI_DATA_LVL_ERROR: something went wrong
1456 - C.XMLUI_DATA_BTNS_SET: one of:
1457 - C.XMLUI_DATA_BTNS_SET_OKCANCEL (default): classical "OK" and "Cancel"
1458 set
1459 - C.XMLUI_DATA_BTNS_SET_YESNO: a translated "yes" for OK, and "no" for
1460 Cancel
1461 - C.XMLUI_DATA_FILETYPE: only used for file dialogs, one of:
1462 - C.XMLUI_DATA_FILETYPE_FILE: a file path is requested
1463 - C.XMLUI_DATA_FILETYPE_DIR: a dir path is requested
1464 - C.XMLUI_DATA_FILETYPE_DEFAULT: same as C.XMLUI_DATA_FILETYPE_FILE
1465
1466 @param title: title or default if None
1467 @param submit_id: callback id to call for panel_type we can submit (form, param,
1468 dialog)
1469 @param session_id: use to keep a session attached to the dialog, must be
1470 returned by frontends
1471 @attribute named_widgets(dict): map from name to widget
1472 """
1473 if panel_type not in [
1474 C.XMLUI_WINDOW,
1475 C.XMLUI_FORM,
1476 C.XMLUI_PARAM,
1477 C.XMLUI_POPUP,
1478 C.XMLUI_DIALOG,
1479 ]:
1480 raise exceptions.DataError(_("Unknown panel type [%s]") % panel_type)
1481 if panel_type == C.XMLUI_FORM and submit_id is None:
1482 raise exceptions.DataError(_("form XMLUI need a submit_id"))
1483 if not isinstance(container, str):
1484 raise exceptions.DataError(_("container argument must be a string"))
1485 if dialog_opt is not None and panel_type != C.XMLUI_DIALOG:
1486 raise exceptions.DataError(
1487 _("dialog_opt can only be used with dialog panels")
1488 )
1489 self.type = panel_type
1490 impl = minidom.getDOMImplementation()
1491
1492 self.doc = impl.createDocument(None, "sat_xmlui", None)
1493 top_element = self.doc.documentElement
1494 top_element.setAttribute("type", panel_type)
1495 if title:
1496 top_element.setAttribute("title", title)
1497 self.submit_id = submit_id
1498 self.session_id = session_id
1499 if panel_type == C.XMLUI_DIALOG:
1500 if dialog_opt is None:
1501 dialog_opt = {}
1502 self._create_dialog(dialog_opt)
1503 return
1504 self.main_container = self._create_container(container, TopElement(self))
1505 self.current_container = self.main_container
1506 self.named_widgets = {}
1507
1508 @staticmethod
1509 def creator_wrapper(widget_cls, is_input):
1510 # TODO: once moved to Python 3, use functools.partialmethod and
1511 # remove the creator_wrapper
1512 def create_widget(self, *args, **kwargs):
1513 if self.type == C.XMLUI_DIALOG:
1514 raise exceptions.InternalError(_(
1515 "create_widget can't be used with dialogs"))
1516 if "parent" not in kwargs:
1517 kwargs["parent"] = self.current_container
1518 if "name" not in kwargs and is_input:
1519 # name can be given as first argument or in keyword
1520 # arguments for InputWidgets
1521 args = list(args)
1522 kwargs["name"] = args.pop(0)
1523 return widget_cls(self, *args, **kwargs)
1524 return create_widget
1525
1526 @classmethod
1527 def _introspect(cls):
1528 """ Introspect module to find Widgets and Containers, and create addXXX methods"""
1529 # FIXME: we can't log anything because this file is used
1530 # in bin/sat script then evaluated
1531 # bin/sat should be refactored
1532 # log.debug(u'introspecting XMLUI widgets and containers')
1533 cls._containers = {}
1534 cls._widgets = {}
1535 for obj in list(globals().values()):
1536 try:
1537 if issubclass(obj, Widget):
1538 if obj.__name__ == "Widget":
1539 continue
1540 cls._widgets[obj.type] = obj
1541 creator_name = "add" + obj.__name__
1542 if creator_name.endswith('Widget'):
1543 creator_name = creator_name[:-6]
1544 is_input = issubclass(obj, InputWidget)
1545 # FIXME: cf. above comment
1546 # log.debug(u"Adding {creator_name} creator (is_input={is_input}))"
1547 # .format(creator_name=creator_name, is_input=is_input))
1548
1549 assert not hasattr(cls, creator_name)
1550 # XXX: we need to use creator_wrapper because we are in a loop
1551 # and Python 2 doesn't support default values in kwargs
1552 # when using *args, **kwargs
1553 setattr(cls, creator_name, cls.creator_wrapper(obj, is_input))
1554
1555 elif issubclass(obj, Container):
1556 if obj.__name__ == "Container":
1557 continue
1558 cls._containers[obj.type] = obj
1559 except TypeError:
1560 pass
1561
1562 def __del__(self):
1563 self.doc.unlink()
1564
1565 @property
1566 def submit_id(self):
1567 top_element = self.doc.documentElement
1568 if not top_element.hasAttribute("submit"):
1569 # getAttribute never return None (it return empty string it attribute doesn't exists)
1570 # so we have to manage None here
1571 return None
1572 value = top_element.getAttribute("submit")
1573 return value
1574
1575 @submit_id.setter
1576 def submit_id(self, value):
1577 top_element = self.doc.documentElement
1578 if value is None:
1579 try:
1580 top_element.removeAttribute("submit")
1581 except NotFoundErr:
1582 pass
1583 else: # submit_id can be the empty string to bypass form restriction
1584 top_element.setAttribute("submit", value)
1585
1586 @property
1587 def session_id(self):
1588 top_element = self.doc.documentElement
1589 value = top_element.getAttribute("session_id")
1590 return value or None
1591
1592 @session_id.setter
1593 def session_id(self, value):
1594 top_element = self.doc.documentElement
1595 if value is None:
1596 try:
1597 top_element.removeAttribute("session_id")
1598 except NotFoundErr:
1599 pass
1600 elif value:
1601 top_element.setAttribute("session_id", value)
1602 else:
1603 raise exceptions.DataError("session_id can't be empty")
1604
1605 def _create_dialog(self, dialog_opt):
1606 dialog_type = dialog_opt.setdefault(C.XMLUI_DATA_TYPE, C.XMLUI_DIALOG_MESSAGE)
1607 if (
1608 dialog_type in [C.XMLUI_DIALOG_CONFIRM, C.XMLUI_DIALOG_FILE]
1609 and self.submit_id is None
1610 ):
1611 raise exceptions.InternalError(
1612 _("Submit ID must be filled for this kind of dialog")
1613 )
1614 top_element = TopElement(self)
1615 level = dialog_opt.get(C.XMLUI_DATA_LVL)
1616 dialog_elt = DialogElement(top_element, dialog_type, level)
1617
1618 try:
1619 MessageElement(dialog_elt, dialog_opt[C.XMLUI_DATA_MESS])
1620 except KeyError:
1621 pass
1622
1623 try:
1624 ButtonsElement(dialog_elt, dialog_opt[C.XMLUI_DATA_BTNS_SET])
1625 except KeyError:
1626 pass
1627
1628 try:
1629 FileElement(dialog_elt, dialog_opt[C.XMLUI_DATA_FILETYPE])
1630 except KeyError:
1631 pass
1632
1633 def _create_container(self, container, parent=None, **kwargs):
1634 """Create a container element
1635
1636 @param type: container type (cf init doc)
1637 @parent: parent element or None
1638 """
1639 if container not in self._containers:
1640 raise exceptions.DataError(_("Unknown container type [%s]") % container)
1641 cls = self._containers[container]
1642 new_container = cls(self, parent=parent, **kwargs)
1643 return new_container
1644
1645 def change_container(self, container, **kwargs):
1646 """Change the current container
1647
1648 @param container: either container type (container it then created),
1649 or an Container instance"""
1650 if isinstance(container, str):
1651 self.current_container = self._create_container(
1652 container,
1653 self.current_container.get_parent_container() or self.main_container,
1654 **kwargs
1655 )
1656 else:
1657 self.current_container = (
1658 self.main_container if container is None else container
1659 )
1660 assert isinstance(self.current_container, Container)
1661 return self.current_container
1662
1663 def add_widget(self, type_, *args, **kwargs):
1664 """Convenience method to add an element"""
1665 if "parent" not in kwargs:
1666 kwargs["parent"] = self.current_container
1667 try:
1668 cls = self._widgets[type_]
1669 except KeyError:
1670 raise exceptions.DataError(_("Invalid type [{type_}]").format(type_=type_))
1671 return cls(self, *args, **kwargs)
1672
1673 def toXml(self):
1674 """return the XML representation of the panel"""
1675 return self.doc.toxml()
1676
1677
1678 # we call this to have automatic discovery of containers and widgets
1679 XMLUI._introspect()
1680
1681
1682 # Some sugar for XMLUI dialogs
1683
1684
1685 def note(message, title="", level=C.XMLUI_DATA_LVL_INFO):
1686 """sugar to easily create a Note Dialog
1687
1688 @param message(unicode): body of the note
1689 @param title(unicode): title of the note
1690 @param level(unicode): one of C.XMLUI_DATA_LVL_*
1691 @return(XMLUI): instance of XMLUI
1692 """
1693 note_xmlui = XMLUI(
1694 C.XMLUI_DIALOG,
1695 dialog_opt={
1696 C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
1697 C.XMLUI_DATA_MESS: message,
1698 C.XMLUI_DATA_LVL: level,
1699 },
1700 title=title,
1701 )
1702 return note_xmlui
1703
1704
1705 def quick_note(host, client, message, title="", level=C.XMLUI_DATA_LVL_INFO):
1706 """more sugar to do the whole note process"""
1707 note_ui = note(message, title, level)
1708 host.action_new({"xmlui": note_ui.toXml()}, profile=client.profile)
1709
1710
1711 def deferred_ui(host, xmlui, chained=False):
1712 """create a deferred linked to XMLUI
1713
1714 @param xmlui(XMLUI): instance of the XMLUI
1715 Must be an XMLUI that you can submit, with submit_id set to ''
1716 @param chained(bool): True if the Deferred result must be returned to the frontend
1717 useful when backend is in a series of dialogs with an ui
1718 @return (D(data)): a deferred which fire the data
1719 """
1720 assert xmlui.submit_id == ""
1721 xmlui_d = defer.Deferred()
1722
1723 def on_submit(data, profile):
1724 xmlui_d.callback(data)
1725 return xmlui_d if chained else {}
1726
1727 xmlui.submit_id = host.register_callback(on_submit, with_data=True, one_shot=True)
1728 return xmlui_d
1729
1730
1731 def defer_xmlui(host, xmlui, action_extra=None, security_limit=C.NO_SECURITY_LIMIT,
1732 chained=False, profile=C.PROF_KEY_NONE):
1733 """Create a deferred linked to XMLUI
1734
1735 @param xmlui(XMLUI): instance of the XMLUI
1736 Must be an XMLUI that you can submit, with submit_id set to ''
1737 @param profile: %(doc_profile)s
1738 @param action_extra(None, dict): extra action to merge with xmlui
1739 mainly used to add meta informations (see action_new doc)
1740 @param security_limit: %(doc_security_limit)s
1741 @param chained(bool): True if the Deferred result must be returned to the frontend
1742 useful when backend is in a series of dialogs with an ui
1743 @return (data): a deferred which fire the data
1744 """
1745 xmlui_d = deferred_ui(host, xmlui, chained)
1746 action_data = {"xmlui": xmlui.toXml()}
1747 if action_extra is not None:
1748 action_data.update(action_extra)
1749 host.action_new(
1750 action_data,
1751 security_limit=security_limit,
1752 keep_id=xmlui.submit_id,
1753 profile=profile,
1754 )
1755 return xmlui_d
1756
1757
1758 def defer_dialog(
1759 host,
1760 message: str,
1761 title: str = "Please confirm",
1762 type_: str = C.XMLUI_DIALOG_CONFIRM,
1763 options: Optional[dict] = None,
1764 action_extra: Optional[dict] = None,
1765 security_limit: int = C.NO_SECURITY_LIMIT,
1766 chained: bool = False,
1767 profile: str = C.PROF_KEY_NONE
1768 ) -> defer.Deferred:
1769 """Create a submitable dialog and manage it with a deferred
1770
1771 @param message: message to display
1772 @param title: title of the dialog
1773 @param type: dialog type (C.XMLUI_DIALOG_* or plugin specific string)
1774 @param options: if not None, will be used to update (extend) dialog_opt arguments of
1775 XMLUI
1776 @param action_extra: extra action to merge with xmlui
1777 mainly used to add meta informations (see action_new doc)
1778 @param security_limit: %(doc_security_limit)s
1779 @param chained: True if the Deferred result must be returned to the frontend
1780 useful when backend is in a series of dialogs with an ui
1781 @param profile: %(doc_profile)s
1782 @return: answer dict
1783 """
1784 assert profile is not None
1785 dialog_opt = {"type": type_, "message": message}
1786 if options is not None:
1787 dialog_opt.update(options)
1788 dialog = XMLUI(C.XMLUI_DIALOG, title=title, dialog_opt=dialog_opt, submit_id="")
1789 return defer_xmlui(host, dialog, action_extra, security_limit, chained, profile)
1790
1791
1792 def defer_confirm(*args, **kwargs):
1793 """call defer_dialog and return a boolean instead of the whole data dict"""
1794 d = defer_dialog(*args, **kwargs)
1795 d.addCallback(lambda data: C.bool(data["answer"]))
1796 return d
1797
1798
1799 # Misc other funtions
1800
1801 def element_copy(
1802 element: domish.Element,
1803 with_parent: bool = True,
1804 with_children: bool = True
1805 ) -> domish.Element:
1806 """Make a copy of a domish.Element
1807
1808 The copy will have its own children list, so other elements
1809 can be added as direct children without modifying orignal one.
1810 Children are not deeply copied, so if an element is added to a child or grandchild,
1811 it will also affect original element.
1812 @param element: Element to clone
1813 """
1814 new_elt = domish.Element(
1815 (element.uri, element.name),
1816 defaultUri = element.defaultUri,
1817 attribs = element.attributes,
1818 localPrefixes = element.localPrefixes)
1819 if with_parent:
1820 new_elt.parent = element.parent
1821 if with_children:
1822 new_elt.children = element.children[:]
1823 return new_elt
1824
1825
1826 def is_xhtml_field(field):
1827 """Check if a data_form.Field is an XHTML one"""
1828 return (field.fieldType is None and field.ext_type == "xml" and
1829 field.value.uri == C.NS_XHTML)
1830
1831
1832 class ElementParser:
1833 """Callable class to parse XML string into Element"""
1834
1835 # XXX: Found at http://stackoverflow.com/questions/2093400/how-to-create-twisted-words-xish-domish-element-entirely-from-raw-xml/2095942#2095942
1836
1837 def _escape_html(self, matchobj):
1838 entity = matchobj.group(1)
1839 if entity in XML_ENTITIES:
1840 # we don't escape XML entities
1841 return matchobj.group(0)
1842 else:
1843 try:
1844 return chr(html.entities.name2codepoint[entity])
1845 except KeyError:
1846 log.warning("removing unknown entity {}".format(entity))
1847 return ""
1848
1849 def __call__(self, raw_xml, force_spaces=False, namespace=None):
1850 """
1851 @param raw_xml(unicode): the raw XML
1852 @param force_spaces (bool): if True, replace occurrences of '\n' and '\t'
1853 with ' '.
1854 @param namespace(unicode, None): if set, use this namespace for the wrapping
1855 element
1856 """
1857 # we need to wrap element in case
1858 # there is not a unique one on the top
1859 if namespace is not None:
1860 raw_xml = "<div xmlns='{}'>{}</div>".format(namespace, raw_xml)
1861 else:
1862 raw_xml = "<div>{}</div>".format(raw_xml)
1863
1864 # avoid ParserError on HTML escaped chars
1865 raw_xml = html_entity_re.sub(self._escape_html, raw_xml)
1866
1867 self.result = None
1868
1869 def on_start(elem):
1870 self.result = elem
1871
1872 def on_end():
1873 pass
1874
1875 def onElement(elem):
1876 self.result.addChild(elem)
1877
1878 parser = domish.elementStream()
1879 parser.DocumentStartEvent = on_start
1880 parser.ElementEvent = onElement
1881 parser.DocumentEndEvent = on_end
1882 tmp = domish.Element((None, "s"))
1883 if force_spaces:
1884 raw_xml = raw_xml.replace("\n", " ").replace("\t", " ")
1885 tmp.addRawXml(raw_xml)
1886 parser.parse(tmp.toXml().encode("utf-8"))
1887 top_elt = self.result.firstChildElement()
1888 # we now can check if there was a unique element on the top
1889 # and remove our wrapping <div/> is this is the case
1890 top_elt_children = list(top_elt.elements())
1891 if len(top_elt_children) == 1:
1892 top_elt = top_elt_children[0]
1893 return top_elt
1894
1895
1896 parse = ElementParser()
1897
1898
1899 # FIXME: this method is duplicated from frontends.tools.xmlui.get_text
1900 def get_text(node):
1901 """Get child text nodes of a domish.Element.
1902
1903 @param node (domish.Element)
1904 @return: joined unicode text of all nodes
1905 """
1906 data = []
1907 for child in node.childNodes:
1908 if child.nodeType == child.TEXT_NODE:
1909 data.append(child.wholeText)
1910 return "".join(data)
1911
1912
1913 def find_all(elt, namespaces=None, names=None):
1914 """Find child element at any depth matching criteria
1915
1916 @param elt(domish.Element): top parent of the elements to find
1917 @param names(iterable[unicode], basestring, None): names to match
1918 None to accept every names
1919 @param namespace(iterable[unicode], basestring, None): URIs to match
1920 None to accept every namespaces
1921 @return ((G)domish.Element): found elements
1922 """
1923 if isinstance(namespaces, str):
1924 namespaces = tuple((namespaces,))
1925 if isinstance(names, str):
1926 names = tuple((names,))
1927
1928 for child in elt.elements():
1929 if (
1930 domish.IElement.providedBy(child)
1931 and (not names or child.name in names)
1932 and (not namespaces or child.uri in namespaces)
1933 ):
1934 yield child
1935 for found in find_all(child, namespaces, names):
1936 yield found
1937
1938
1939 def find_ancestor(
1940 elt,
1941 name: str,
1942 namespace: Optional[Union[str, Iterable[str]]] = None
1943 ) -> domish.Element:
1944 """Retrieve ancestor of an element
1945
1946 @param elt: starting element, its parent will be checked recursively until the
1947 required one if found
1948 @param name: name of the element to find
1949 @param namespace: namespace of the element to find
1950 - None to find any element with that name
1951 - a simple string to find the namespace
1952 - several namespaces can be specified in an iterable, if an element with any of
1953 this namespace and given name is found, it will match
1954
1955 """
1956 if isinstance(namespace, str):
1957 namespace = [namespace]
1958 current = elt.parent
1959 while True:
1960 if current is None:
1961 raise exceptions.NotFound(
1962 f"Can't find any ancestor {name!r} (xmlns: {namespace!r})"
1963 )
1964 if current.name == name and (namespace is None or current.uri in namespace):
1965 return current
1966 current = current.parent
1967
1968
1969 def p_fmt_elt(elt, indent=0, defaultUri=""):
1970 """Pretty format a domish.Element"""
1971 strings = []
1972 for child in elt.children:
1973 if domish.IElement.providedBy(child):
1974 strings.append(p_fmt_elt(child, indent+2, defaultUri=elt.defaultUri))
1975 else:
1976 strings.append(f"{(indent+2)*' '}{child!s}")
1977 if elt.children:
1978 nochild_elt = domish.Element(
1979 (elt.uri, elt.name), elt.defaultUri, elt.attributes, elt.localPrefixes
1980 )
1981 strings.insert(0, f"{indent*' '}{nochild_elt.toXml(defaultUri=defaultUri)[:-2]}>")
1982 strings.append(f"{indent*' '}</{nochild_elt.name}>")
1983 else:
1984 strings.append(f"{indent*' '}{elt.toXml(defaultUri=defaultUri)}")
1985 return '\n'.join(strings)
1986
1987
1988 def pp_elt(elt):
1989 """Pretty print a domish.Element"""
1990 print(p_fmt_elt(elt))
1991
1992
1993 # ElementTree
1994
1995 def et_get_namespace_and_name(et_elt: ET.Element) -> Tuple[Optional[str], str]:
1996 """Retrieve element namespace and name from ElementTree element
1997
1998 @param et_elt: ElementTree element
1999 @return: namespace and name of the element
2000 if not namespace if specified, None is returned
2001 """
2002 name = et_elt.tag
2003 if not name:
2004 raise ValueError("no name set in ET element")
2005 elif name[0] != "{":
2006 return None, name
2007 end_idx = name.find("}")
2008 if end_idx == -1:
2009 raise ValueError("Invalid ET name")
2010 return name[1:end_idx], name[end_idx+1:]
2011
2012
2013 def et_elt_2_domish_elt(et_elt: Union[ET.Element, etree.Element]) -> domish.Element:
2014 """Convert ElementTree element to Twisted's domish.Element
2015
2016 Note: this is a naive implementation, adapted to XMPP, and some content are ignored
2017 (attributes namespaces, tail)
2018 """
2019 namespace, name = et_get_namespace_and_name(et_elt)
2020 elt = domish.Element((namespace, name), attribs=et_elt.attrib)
2021 if et_elt.text:
2022 elt.addContent(et_elt.text)
2023 for child in et_elt:
2024 elt.addChild(et_elt_2_domish_elt(child))
2025 return elt
2026
2027
2028 @overload
2029 def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[False]) -> ET.Element:
2030 ...
2031
2032 @overload
2033 def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[True]) -> etree.Element:
2034 ...
2035
2036 @overload
2037 def domish_elt_2_et_elt(
2038 elt: domish.Element, lxml: bool
2039 ) -> Union[ET.Element, etree.Element]:
2040 ...
2041
2042 def domish_elt_2_et_elt(elt, lxml = False):
2043 """Convert Twisted's domish.Element to ElementTree equivalent
2044
2045 Note: this is a naive implementation, adapted to XMPP, and some text content may be
2046 missing (content put after a tag, i.e. what would go to the "tail" attribute of ET
2047 Element)
2048 """
2049 tag = f"{{{elt.uri}}}{elt.name}" if elt.uri else elt.name
2050 if lxml:
2051 et_elt = etree.Element(tag, attr=elt.attributes)
2052 else:
2053 et_elt = ET.Element(tag, attrib=elt.attributes)
2054 content = str(elt)
2055 if content:
2056 et_elt.text = str(elt)
2057 for child in elt.elements():
2058 et_elt.append(domish_elt_2_et_elt(child, lxml=lxml))
2059 return et_elt
2060
2061
2062 def domish_elt_2_et_elt2(element: domish.Element) -> ET.Element:
2063 """
2064 WIP, originally from the OMEMO plugin
2065 """
2066
2067 element_name = element.name
2068 if element.uri is not None:
2069 element_name = "{" + element.uri + "}" + element_name
2070
2071 attrib: Dict[str, str] = {}
2072 for qname, value in element.attributes.items():
2073 attribute_name = qname[1] if isinstance(qname, tuple) else qname
2074 attribute_namespace = qname[0] if isinstance(qname, tuple) else None
2075 if attribute_namespace is not None:
2076 attribute_name = "{" + attribute_namespace + "}" + attribute_name
2077
2078 attrib[attribute_name] = value
2079
2080 result = ET.Element(element_name, attrib)
2081
2082 last_child: Optional[ET.Element] = None
2083 for child in element.children:
2084 if isinstance(child, str):
2085 if last_child is None:
2086 result.text = child
2087 else:
2088 last_child.tail = child
2089 else:
2090 last_child = domish_elt_2_et_elt2(child)
2091 result.append(last_child)
2092
2093 return result