Mercurial > libervia-backend
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 |