# HG changeset patch # User souliane # Date 1440176531 -7200 # Node ID e3330ce6528505918da9a904fee492378d4a7a98 # Parent 7a9cef71ae4349370eb0aae44df2a3ca14128362 plugin XEP-0055: add "simple" and "advanced" modes to Jabber search: - simple search queries the service offered by the user's server, asks for a single input and compile the results of the queries done on each search field - advanced allows to select the search service and to perform a "normal" search (the user can fill in all the fields that are implemented by the service) diff -r 7a9cef71ae43 -r e3330ce65285 src/plugins/plugin_xep_0055.py --- a/src/plugins/plugin_xep_0055.py Sat Aug 22 10:28:07 2015 +0200 +++ b/src/plugins/plugin_xep_0055.py Fri Aug 21 19:02:11 2015 +0200 @@ -20,14 +20,24 @@ from sat.core.i18n import _, D_ from sat.core.log import getLogger log = getLogger(__name__) + from twisted.words.protocols.jabber.xmlstream import IQ from twisted.words.protocols.jabber import jid from twisted.internet import defer from wokkel import data_form +from sat.core.constants import Const as C from sat.core.exceptions import DataError from sat.memory.memory import Sessions from sat.tools import xml_tools +from wokkel import disco, iwokkel +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler +from zope.interface import implements + + NS_SEARCH = 'jabber:iq:search' PLUGIN_INFO = { @@ -42,142 +52,389 @@ "description": _("""Implementation of Jabber Search""") } +# config file parameters +CONFIG_SECTION = "plugin search" +CONFIG_SERVICE_LIST = "service_list" + +DEFAULT_SERVICE_LIST = ["salut.libervia.org"] + +FIELD_SINGLE = "field_single" # single text field for the simple search +FIELD_CURRENT_SERVICE = "current_service_jid" # read-only text field for the advanced search class XEP_0055(object): def __init__(self, host): log.info(_("Jabber search plugin initialization")) self.host = host - host.bridge.addMethod("getSearchUI", ".plugin", in_sign='ss', out_sign='s', - method=self._getSearchUI, + + # default search services (config file + hard-coded lists) + self.services = [jid.JID(entry) for entry in host.memory.getConfig(CONFIG_SECTION, CONFIG_SERVICE_LIST, DEFAULT_SERVICE_LIST)] + + host.bridge.addMethod("searchGetFieldsUI", ".plugin", in_sign='ss', out_sign='s', + method=self._getFieldsUI, async=True) host.bridge.addMethod("searchRequest", ".plugin", in_sign='sa{ss}s', out_sign='s', method=self._searchRequest, async=True) - self._sessions = Sessions() - self.__menu_cb_id = host.registerCallback(self._menuCb, with_data=True) - self.__search_request_id = host.registerCallback(self._xmluiSearchRequest, with_data=True) - host.importMenu((D_("Communication"), D_("Search directory")), self._searchMenu, security_limit=1, help_string=D_("Search use directory")) - def _menuCb(self, data, profile): - entity = jid.JID(data[xml_tools.SAT_FORM_PREFIX+'jid']) - d = self.getSearchUI(entity, profile) - def gotXMLUI(xmlui): - session_id, session_data = self._sessions.newSession(profile=profile) - session_data['jid'] = entity - xmlui.session_id = session_id # we need to keep track of the session - xmlui.submit_id = self.__search_request_id - return {'xmlui': xmlui.toXml()} - d.addCallback(gotXMLUI) - return d + self.__search_menu_id = host.registerCallback(self._getMainUI, with_data=True) + host.importMenu((D_("Communication"), D_("Search directory")), self._getMainUI, security_limit=1, help_string=D_("Search use directory")) + + + ## Main search UI (menu item callback) ## - def _searchMenu(self, menu_data, profile): - """ First XMLUI activated by menu: ask for target jid - @param profile: %(doc_profile)s + def _getMainUI(self, raw_data, profile): + """Get the XMLUI for selecting a service and searching the directory. + + @param raw_data (dict): data received from the frontend + @param profile (unicode): %(doc_profile)s + @return: a deferred XMLUI string representation + """ + # check if the user's server offers some search services + d = self.host.findFeaturesSet([NS_SEARCH], profile_key=profile) + return d.addCallback(lambda services: self.getMainUI(list(services), raw_data, profile)) + + def getMainUI(self, services, raw_data, profile): + """Get the XMLUI for selecting a service and searching the directory. + @param services (list[jid.JID]): search services offered by the user server + @param raw_data (dict): data received from the frontend + @param profile (unicode): %(doc_profile)s + @return: a deferred XMLUI string representation + """ + # extend services offered by user's server with the default services + services.extend([service for service in self.services if service not in services]) + data = xml_tools.XMLUIResult2DataFormResult(raw_data) + main_ui = xml_tools.XMLUI(C.XMLUI_WINDOW, container="tabs", title=_("Search users"), submit_id=self.__search_menu_id) + + d = self._addSimpleSearchUI(services, main_ui, data, profile) + d.addCallback(lambda dummy: self._addAdvancedSearchUI(services, main_ui, data, profile)) + return d.addCallback(lambda dummy: {'xmlui': main_ui.toXml()}) + + def _addSimpleSearchUI(self, services, main_ui, data, profile): + """Add to the main UI a tab for the simple search. + + Display a single input field and search on the main service (it actually does one search per search field and then compile the results). + + @param services (list[jid.JID]): search services offered by the user server + @param main_ui (XMLUI): the main XMLUI instance + @param data (dict): form data without SAT_FORM_PREFIX + @param profile (unicode): %(doc_profile)s + + @return: a dummy Deferred """ - form_ui = xml_tools.XMLUI("form", title=_("Search directory"), submit_id=self.__menu_cb_id) - form_ui.addText(_("Please enter the search jid"), 'instructions') - form_ui.changeContainer("pairs") - form_ui.addLabel("jid") - # form_ui.addString("jid", value="users.jabberfr.org") # TODO: replace users.jabberfr.org by any XEP-0055 compatible service discovered on current server - form_ui.addString("jid", value="salut.libervia.org") # TODO: replace salut.libervia.org by any XEP-0055 compatible service discovered on current server - return {'xmlui': form_ui.toXml()} + service_jid = services[0] # TODO: search on all the given services, not only the first one + + form = data_form.Form('form', formNamespace=NS_SEARCH) + form.addField(data_form.Field('text-single', FIELD_SINGLE, label=_('Search for'), value=data.get(FIELD_SINGLE, ''))) + + sub_cont = main_ui.main_container.addTab("simple_search", label=_("Simple search"), container=xml_tools.VerticalContainer) + main_ui.changeContainer(sub_cont.append(xml_tools.PairsContainer(main_ui))) + xml_tools.dataForm2Widgets(main_ui, form) + + # FIXME: add colspan attribute to divider? (we are in a PairsContainer) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # here we added a blank line before the button + main_ui.addDivider('blank') + main_ui.addButton(self.__search_menu_id, _("Search"), (FIELD_SINGLE,)) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # a blank line again after the button + + simple_data = {key: value for key, value in data.iteritems() if key in (FIELD_SINGLE,)} + if simple_data: + log.debug("Simple search with %s on %s" % (simple_data, service_jid)) + sub_cont.parent.setSelected(True) + main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui))) + main_ui.addDivider('dash') + d = self.searchRequest(service_jid, simple_data, profile) + d.addCallbacks(lambda elt: self._displaySearchResult(main_ui, elt), + lambda failure: main_ui.addText(failure.getErrorMessage())) + return d + + return defer.succeed(None) + + def _addAdvancedSearchUI(self, services, main_ui, data, profile): + """Add to the main UI a tab for the advanced search. + + Display a service selector and allow to search on all the fields that are implemented by the selected service. + + @param services (list[jid.JID]): search services offered by the user server + @param main_ui (XMLUI): the main XMLUI instance + @param data (dict): form data without SAT_FORM_PREFIX + @param profile (unicode): %(doc_profile)s - def _getSearchUI(self, to_jid_s, profile_key): - d = self.getSearchUI(jid.JID(to_jid_s), profile_key) - d.addCallback(lambda xmlui: xmlui.toXml()) + @return: a dummy Deferred + """ + sub_cont = main_ui.main_container.addTab("advanced_search", label=_("Advanced search"), container=xml_tools.VerticalContainer) + service_selection_fields = ['service_jid', 'service_jid_extra'] + + if 'service_jid_extra' in data: + # refresh button has been pushed, select the tab + sub_cont.parent.setSelected(True) + # get the selected service + service_jid_s = data.get('service_jid_extra', '') + if not service_jid_s: + service_jid_s = data.get('service_jid', unicode(services[0])) + log.debug("Refreshing search fields for %s" % service_jid_s) + else: + service_jid_s = data.get(FIELD_CURRENT_SERVICE, unicode(services[0])) + services_s = [unicode(service) for service in services] + if service_jid_s not in services_s: + services_s.append(service_jid_s) + + main_ui.changeContainer(sub_cont.append(xml_tools.PairsContainer(main_ui))) + main_ui.addLabel(_("Search on")) + main_ui.addList('service_jid', options=services_s, selected=service_jid_s) + main_ui.addLabel(_("Other service")) + main_ui.addString(name='service_jid_extra') + + # FIXME: add colspan attribute to divider? (we are in a PairsContainer) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # here we added a blank line before the button + main_ui.addDivider('blank') + main_ui.addButton(self.__search_menu_id, _("Refresh fields"), service_selection_fields) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # a blank line again after the button + main_ui.addLabel(_("Displaying the search form for")) + main_ui.addString(name=FIELD_CURRENT_SERVICE, value=service_jid_s, read_only=True) + main_ui.addDivider('dash') + main_ui.addDivider('dash') + + main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui))) + service_jid = jid.JID(service_jid_s) + d = self.getFieldsUI(service_jid, profile) + d.addCallbacks(self._addAdvancedForm, lambda failure: main_ui.addText(failure.getErrorMessage()), + [service_jid, main_ui, sub_cont, data, profile]) return d - def getSearchUI(self, to_jid, profile_key): - """ Ask for a search interface - @param to_jid: XEP-0055 compliant search entity - @param profile_key: %(doc_profile_key)s - @return: XMLUI search interface """ + def _addAdvancedForm(self, form_elt, service_jid, main_ui, sub_cont, data, profile): + """Add the search form and the search results (if there is some to display). + + @param form_elt (domish.Element): form element listing the fields + @param service_jid (jid.JID): current search service + @param main_ui (XMLUI): the main XMLUI instance + @param sub_cont (Container): the container of the current tab + @param data (dict): form data without SAT_FORM_PREFIX + @param profile (unicode): %(doc_profile)s + + @return: a dummy Deferred + """ + field_list = data_form.Form.fromElement(form_elt).fieldList + adv_fields = [field.var for field in field_list if field.var] + adv_data = {key: value for key, value in data.iteritems() if key in adv_fields} + + xml_tools.dataForm2Widgets(main_ui, data_form.Form.fromElement(form_elt)) + + # refill the submitted values + # FIXME: wokkel's data_form.Form.fromElement doesn't parse the values, so we do it directly in XMLUI for now + for widget in main_ui.current_container.elem.childNodes: + name = widget.getAttribute("name") + if adv_data.get(name): + widget.setAttribute("value", adv_data[name]) + + # FIXME: add colspan attribute to divider? (we are in a PairsContainer) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # here we added a blank line before the button + main_ui.addDivider('blank') + main_ui.addButton(self.__search_menu_id, _("Search"), adv_fields + [FIELD_CURRENT_SERVICE]) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # a blank line again after the button + + if adv_data: # display the search results + log.debug("Advanced search with %s on %s" % (adv_data, service_jid)) + sub_cont.parent.setSelected(True) + main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui))) + main_ui.addDivider('dash') + d = self.searchRequest(service_jid, adv_data, profile) + d.addCallbacks(lambda elt: self._displaySearchResult(main_ui, elt), + lambda failure: main_ui.addText(failure.getErrorMessage())) + return d + + return defer.succeed(None) + + + def _displaySearchResult(self, main_ui, elt): + """Display the search results. + + @param main_ui (XMLUI): the main XMLUI instance + @param elt (domish.Element): form result element + """ + if [child for child in elt.children if child.name == "item"]: + xml_tools.dataFormResult2AdvancedList(main_ui, elt) + else: + main_ui.addText(D_("The search gave no result")) + + ## Retrieve the search fields ## + + + def _getFieldsUI(self, to_jid_s, profile_key): + """Ask a service to send us the list of the form fields it manages. + + @param to_jid_s (unicode): XEP-0055 compliant search entity + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred XMLUI instance + """ + d = self.getFieldsUI(jid.JID(to_jid_s), profile_key) + d.addCallback(lambda form: xml_tools.dataFormResult2XMLUI(form).toXml()) + return d + + def getFieldsUI(self, to_jid, profile_key): + """Ask a service to send us the list of the form fields it manages. + + @param to_jid (jid.JID): XEP-0055 compliant search entity + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred domish.Element + """ client = self.host.getClient(profile_key) fields_request = IQ(client.xmlstream, 'get') fields_request["from"] = client.jid.full() fields_request["to"] = to_jid.full() fields_request.addElement('query', NS_SEARCH) d = fields_request.send(to_jid.full()) - d.addCallbacks(self._fieldsOk, self._fieldsErr, callbackArgs=[client.profile], errbackArgs=[client.profile]) + d.addCallbacks(self._getFieldsUICb, self._getFieldsUIEb) return d - def _fieldsOk(self, answer, profile): - """got fields available""" + def _getFieldsUICb(self, answer): + """Callback for self.getFieldsUI. + + @param answer (domish.Element): search query element + @return: domish.Element + """ try: query_elts = answer.elements('jabber:iq:search', 'query').next() except StopIteration: log.info(_("No query element found")) - raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC + raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC try: form_elt = query_elts.elements(data_form.NS_X_DATA, 'x').next() except StopIteration: log.info(_("No data form found")) raise NotImplementedError("Only search through data form is implemented so far") - parsed_form = data_form.Form.fromElement(form_elt) - return xml_tools.dataForm2XMLUI(parsed_form, "") + return form_elt + + def _getFieldsUIEb(self, failure): + """Errback to self.getFieldsUI. - def _fieldsErr(self, failure, profile): - """ Called when something is wrong with fields request """ - log.info(_("Fields request failure: %s") % unicode(failure.value)) - return failure + @param failure (defer.failure.Failure): twisted failure + @raise: the unchanged defer.failure.Failure + """ + log.info(_("Fields request failure: %s") % unicode(failure.getErrorMessage())) + raise failure + - def _xmluiSearchRequest(self, raw_data, profile): - try: - session_data = self._sessions.profileGet(raw_data["session_id"], profile) - except KeyError: - log.warning ("session id doesn't exist, session has probably expired") - # TODO: send error dialog - return defer.succeed({}) + ## Do the search ## + + + def _searchRequest(self, to_jid_s, search_data, profile_key): + """Actually do a search, according to filled data. - data = xml_tools.XMLUIResult2DataFormResult(raw_data) - entity =session_data['jid'] - d = self.searchRequest(entity, data, profile) - d.addCallback(lambda xmlui: {'xmlui':xmlui.toXml()}) - del self._sessions[raw_data["session_id"]] + @param to_jid_s (unicode): XEP-0055 compliant search entity + @param search_data (dict): filled data, corresponding to the form obtained in getFieldsUI + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred XMLUI string representation + """ + d = self.searchRequest(jid.JID(to_jid_s), search_data, profile_key) + d.addCallback(lambda form: xml_tools.dataFormResult2XMLUI(form).toXml()) return d - def _searchRequest(self, to_jid_s, search_dict, profile_key): - d = self.searchRequest(jid.JID(to_jid_s), search_dict, profile_key) - d.addCallback(lambda xmlui: xmlui.toXml()) - return d + def searchRequest(self, to_jid, search_data, profile_key): + """Actually do a search, according to filled data. - def searchRequest(self, to_jid, search_dict, profile_key): - """ Actually do a search, according to filled data - @param to_jid: XEP-0055 compliant search entity - @param search_dict: filled data, corresponding to the form obtained in getSearchUI - @param profile_key: %(doc_profile_key)s - @return: XMLUI search result """ + @param to_jid (jid.JID): XEP-0055 compliant search entity + @param search_data (dict): filled data, corresponding to the form obtained in getFieldsUI + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred domish.Element + """ + if FIELD_SINGLE in search_data: + value = search_data[FIELD_SINGLE] + d = self.getFieldsUI(to_jid, profile_key) + d.addCallback(lambda elt: self.searchRequestMulti(to_jid, value, elt, profile_key)) + return d + client = self.host.getClient(profile_key) search_request = IQ(client.xmlstream, 'set') search_request["from"] = client.jid.full() search_request["to"] = to_jid.full() query_elt = search_request.addElement('query', NS_SEARCH) - x_form = data_form.Form('submit', formNamespace = NS_SEARCH) - x_form.makeFields(search_dict) + x_form = data_form.Form('submit', formNamespace=NS_SEARCH) + x_form.makeFields(search_data) query_elt.addChild(x_form.toElement()) # TODO: XEP-0059 could be used here (with the needed new method attributes) d = search_request.send(to_jid.full()) - d.addCallbacks(self._searchOk, self._searchErr, callbackArgs=[client.profile], errbackArgs=[client.profile]) + d.addCallbacks(self._searchOk, self._searchErr) return d - def _searchOk(self, answer, profile): - """got search available""" + def searchRequestMulti(self, to_jid, value, form_elt, profile_key): + """Search for a value simultaneously in all fields, returns the results compilation. + + @param to_jid (jid.JID): XEP-0055 compliant search entity + @param value (unicode): value to search + @param form_elt (domish.Element): form element listing the fields + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred domish.Element + """ + form = data_form.Form.fromElement(form_elt) + d_list = [] + for field in [field.var for field in form.fieldList if field.var]: + d_list.append(self.searchRequest(to_jid, {field: value}, profile_key)) + break + + def cb(result): # return the results compiled in one domish element + result_elt = None + for success, form_elt in result: + if not success: + continue + if result_elt is None: # the result element is built over the first answer + result_elt = form_elt + continue + for item_elt in form_elt.elements('jabber:x:data', 'item'): + result_elt.addChild(item_elt) + if result_elt is None: + raise defer.failure.Failure(DataError(_("The search could not be performed"))) + return result_elt + + return defer.DeferredList(d_list).addCallback(cb) + + def _searchOk(self, answer): + """Callback for self.searchRequest. + + @param answer (domish.Element): search query element + @return: domish.Element + """ try: query_elts = answer.elements('jabber:iq:search', 'query').next() except StopIteration: log.info(_("No query element found")) - raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC + raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC try: form_elt = query_elts.elements(data_form.NS_X_DATA, 'x').next() except StopIteration: log.info(_("No data form found")) raise NotImplementedError("Only search through data form is implemented so far") - return xml_tools.dataFormResult2XMLUI(form_elt) + return form_elt + + def _searchErr(self, failure): + """Errback to self.searchRequest. + + @param failure (defer.failure.Failure): twisted failure + @raise: the unchanged defer.failure.Failure + """ + log.info(_("Search request failure: %s") % unicode(failure.getErrorMessage())) + raise failure + - def _searchErr(self, failure, profile): - """ Called when something is wrong with search request """ - log.info(_("Search request failure: %s") % unicode(failure.value)) - return failure +class XEP_0059_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_SEARCH)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] +