We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Catalog search does not handle correctly parenthesis inside quotes
## BUG/PROBLEM REPORT (OR OTHER COMMON ISSUE)

### What I did:

- Add a page with parenthesis enclosed text as a title
- Try to search that page using quoted query

### What I expect to happen:

- The search searches the page

### What actually happened:

- The search does not find the page
- When accessing through REST API (because plone.app.querystring uses the `munge_search_term` function here), we get an error because the query is transformed to an unknown string for ZCatalog

### What version of Plone/ Addons I am using:

- Plone 6


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `Products/CMFPlone/browser/search.py`
Content:
```
1 from DateTime import DateTime
2 from plone.app.contentlisting.interfaces import IContentListing
3 from plone.app.layout.navigation.interfaces import INavigationRoot
4 from plone.base.batch import Batch
5 from plone.base.interfaces import ISearchSchema
6 from plone.base.interfaces.siteroot import IPloneSiteRoot
7 from plone.registry.interfaces import IRegistry
8 from Products.CMFCore.utils import getToolByName
9 from Products.CMFPlone.browser.navtree import getNavigationRoot
10 from Products.ZCTextIndex.ParseTree import ParseError
11 from zope.cachedescriptors.property import Lazy as lazy_property
12 from zope.component import getMultiAdapter
13 from zope.component import getUtility
14 from zope.component import queryUtility
15 from zope.i18nmessageid import MessageFactory
16 from zope.publisher.browser import BrowserView
17 from ZTUtils import make_query
18 
19 import json
20 import re
21 
22 
23 _ = MessageFactory("plone")
24 
25 # We should accept both a simple space, unicode u'\u0020 but also a
26 # multi-space, so called 'waji-kankaku', unicode u'\u3000'
27 MULTISPACE = "\u3000"
28 BAD_CHARS = ("?", "-", "+", "*", MULTISPACE)
29 EVER = DateTime("1970-01-03")
30 
31 
32 def quote_chars(s):
33     # We need to quote parentheses when searching text indices
34     if "(" in s:
35         s = s.replace("(", '"("')
36     if ")" in s:
37         s = s.replace(")", '")"')
38     if MULTISPACE in s:
39         s = s.replace(MULTISPACE, " ")
40     return s
41 
42 
43 def quote(term):
44     # The terms and, or and not must be wrapped in quotes to avoid
45     # being parsed as logical query atoms.
46     if term.lower() in ("and", "or", "not"):
47         term = '"%s"' % term
48     return term
49 
50 
51 def munge_search_term(query):
52     for char in BAD_CHARS:
53         query = query.replace(char, " ")
54 
55     # extract quoted phrases first
56     quoted_phrases = re.findall(r'"([^"]*)"', query)
57     r = []
58     for qp in quoted_phrases:
59         # remove from original query
60         query = query.replace(f'"{qp}"', "")
61         # replace with cleaned leading/trailing whitespaces
62         # and skip empty phrases
63         clean_qp = qp.strip()
64         if not clean_qp:
65             continue
66         r.append(f'"{clean_qp}"')
67 
68     r += map(quote, query.strip().split())
69     r = " AND ".join(r)
70     r = quote_chars(r) + ("*" if r and not r.endswith('"') else "")
71     return r
72 
73 
74 class Search(BrowserView):
75     valid_keys = ("sort_on", "sort_order", "sort_limit", "fq", "fl", "facet")
76 
77     def results(
78         self, query=None, batch=True, b_size=10, b_start=0, use_content_listing=True
79     ):
80         """Get properly wrapped search results from the catalog.
81         Everything in Plone that performs searches should go through this view.
82         'query' should be a dictionary of catalog parameters.
83         """
84         if query is None:
85             query = {}
86         if batch:
87             query["b_start"] = b_start = int(b_start)
88             query["b_size"] = b_size
89         query = self.filter_query(query)
90 
91         if query is None:
92             results = []
93         else:
94             catalog = getToolByName(self.context, "portal_catalog")
95             try:
96                 results = catalog(**query)
97             except ParseError:
98                 return []
99 
100         if use_content_listing:
101             results = IContentListing(results)
102         if batch:
103             results = Batch(results, b_size, b_start)
104         return results
105 
106     def _filter_query(self, query):
107         request = self.request
108 
109         catalog = getToolByName(self.context, "portal_catalog")
110         valid_indexes = tuple(catalog.indexes())
111         valid_keys = self.valid_keys + valid_indexes
112 
113         text = query.get("SearchableText", None)
114         if text is None:
115             text = request.form.get("SearchableText", "")
116         if not text:
117             # Without text, must provide a meaningful non-empty search
118             valid = set(valid_indexes).intersection(request.form.keys()) or set(
119                 valid_indexes
120             ).intersection(query.keys())
121             if not valid:
122                 return
123 
124         for k, v in request.form.items():
125             if v and ((k in valid_keys) or k.startswith("facet.")):
126                 query[k] = v
127         if text:
128             query["SearchableText"] = munge_search_term(text)
129 
130         # don't filter on created at all if we want all results
131         created = query.get("created")
132         if created:
133             try:
134                 if created.get("query", EVER) <= EVER:
135                     del query["created"]
136             except AttributeError:
137                 # created not a mapping
138                 del query["created"]
139 
140         # respect `types_not_searched` setting
141         types = query.get("portal_type", [])
142         if "query" in types:
143             types = types["query"]
144         query["portal_type"] = self.filter_types(types)
145         # respect effective/expiration date
146         query["show_inactive"] = False
147         # respect navigation root if we're not at the site root.
148         if "path" not in query and not IPloneSiteRoot.providedBy(self.context):
149             query["path"] = getNavigationRoot(self.context)
150 
151         if "sort_order" in query and not query["sort_order"]:
152             del query["sort_order"]
153         return query
154 
155     @lazy_property
156     def default_sort_on(self):
157         registry = getUtility(IRegistry)
158         search_settings = registry.forInterface(ISearchSchema, prefix="plone")
159         return search_settings.sort_on
160 
161     def filter_query(self, query):
162         query = self._filter_query(query)
163         if query is None:
164             query = {}
165         # explicitly set a sort; if no `sort_on` is present, the catalog sorts
166         # by relevance
167         if "sort_on" not in query:
168             self.default_sort_on
169             if self.default_sort_on != "relevance":
170                 query["sort_on"] = self.default_sort_on
171         elif query["sort_on"] == "relevance":
172             del query["sort_on"]
173         if query.get("sort_on", "") == "Date":
174             query["sort_order"] = "reverse"
175         elif "sort_order" in query:
176             del query["sort_order"]
177         if not query:
178             return None
179         return query
180 
181     def filter_types(self, types):
182         plone_utils = getToolByName(self.context, "plone_utils")
183         if not isinstance(types, list):
184             types = [types]
185         return plone_utils.getUserFriendlyTypes(types)
186 
187     def types_list(self):
188         # only show those types that have any content
189         catalog = getToolByName(self.context, "portal_catalog")
190         used_types = catalog._catalog.getIndex("portal_type").uniqueValues()
191         return self.filter_types(list(used_types))
192 
193     def sort_options(self):
194         """Sorting options for search results view."""
195         if "sort_on" not in self.request.form:
196             self.request.form["sort_on"] = self.default_sort_on
197         return (
198             SortOption(self.request, _("relevance"), "relevance"),
199             SortOption(self.request, _("date (newest first)"), "Date", reverse=True),
200             SortOption(self.request, _("alphabetically"), "sortable_title"),
201         )
202 
203     def show_advanced_search(self):
204         """Whether we need to show advanced search options a.k.a. filters?"""
205         show = self.request.get("advanced_search", None)
206         if not show or show == "False":
207             return False
208         return True
209 
210     def advanced_search_trigger(self):
211         """URL builder for show/close advanced search filters."""
212         query = self.request.get("QUERY_STRING", None)
213         url = self.request.get("ACTUAL_URL", self.context.absolute_url())
214         if not query:
215             return url
216         if "advanced_search" in query:
217             if "advanced_search=True" in query:
218                 query = query.replace("advanced_search=True", "")
219             if "advanced_search=False" in query:
220                 query = query.replace("advanced_search=False", "")
221         else:
222             query = query + "&advanced_search=True"
223         return url + "?" + query
224 
225     def breadcrumbs(self, item):
226         obj = item.getObject()
227         view = getMultiAdapter((obj, self.request), name="breadcrumbs_view")
228         # cut off the item itself
229         breadcrumbs = list(view.breadcrumbs())[:-1]
230         if len(breadcrumbs) == 0:
231             # don't show breadcrumbs if we only have a single element
232             return None
233         if len(breadcrumbs) > 3:
234             # if we have too long breadcrumbs, emit the middle elements
235             empty = {"absolute_url": "", "Title": "…"}
236             breadcrumbs = [breadcrumbs[0], empty] + breadcrumbs[-2:]
237         return breadcrumbs
238 
239     def navroot_url(self):
240         if not hasattr(self, "_navroot_url"):
241             state = self.context.unrestrictedTraverse("@@plone_portal_state")
242             self._navroot_url = state.navigation_root_url()
243         return self._navroot_url
244 
245     @property
246     def show_images(self):
247         registry = queryUtility(IRegistry)
248         return registry.get("plone.search_show_images")
249 
250     @property
251     def search_image_scale(self):
252         registry = queryUtility(IRegistry)
253         return registry.get("plone.search_image_scale")
254 
255 
256 class AjaxSearch(Search):
257     def __call__(self):
258         items = []
259         try:
260             per_page = int(self.request.form.get("perPage"))
261         except Exception:
262             per_page = 10
263         try:
264             page = int(self.request.form.get("page"))
265         except Exception:
266             page = 1
267 
268         results = self.results(batch=False, use_content_listing=False)
269         batch = Batch(results, per_page, start=(page - 1) * per_page)
270 
271         registry = queryUtility(IRegistry)
272         length = registry.get("plone.search_results_description_length")
273         show_images = registry.get("plone.search_show_images")
274         if show_images:
275             image_scale = registry.get("plone.search_image_scale")
276             # image_scaling = getMultiAdapter((self.context, self.request), name='image_scale')
277             self.image_scaling = getMultiAdapter(
278                 (INavigationRoot(self.context), self.request), name="image_scale"
279             )
280         plone_view = getMultiAdapter((self.context, self.request), name="plone")
281         view_action_types = registry.get("plone.types_use_view_action_in_listings", [])
282         for item in batch:
283             url = item.getURL()
284             if item.portal_type in view_action_types:
285                 url = "%s/view" % url
286             img_tag = None
287             if show_images:
288                 img_tag = self.get_image_tag(item, image_scale)
289             items.append(
290                 {
291                     "id": item.UID,
292                     "title": item.Title,
293                     "description": plone_view.cropText(item.Description, length),
294                     "url": url,
295                     "state": item.review_state if item.review_state else None,
296                     "img_tag": img_tag,
297                 }
298             )
299         return json.dumps({"total": len(results), "items": items})
300 
301     def get_image_tag(self, item, image_scale):
302         return self.image_scaling.tag(item, "image", scale=image_scale)
303 
304 
305 class SortOption:
306     def __init__(self, request, title, sortkey="", reverse=False):
307         self.request = request
308         self.title = title
309         self.sortkey = sortkey
310         self.reverse = reverse
311 
312     def selected(self):
313         sort_on = self.request.get("sort_on", "")
314         return sort_on == self.sortkey and sort_on != ""
315 
316     def url(self):
317         q = {}
318         q.update(self.request.form)
319         if "sort_on" in q.keys():
320             del q["sort_on"]
321         if "sort_order" in q.keys():
322             del q["sort_order"]
323         q["sort_on"] = self.sortkey
324         if self.reverse:
325             q["sort_order"] = "reverse"
326 
327         base_url = self.request.URL
328         return base_url + "?" + make_query(q)
329
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/Products/CMFPlone/browser/search.py b/Products/CMFPlone/browser/search.py
--- a/Products/CMFPlone/browser/search.py
+++ b/Products/CMFPlone/browser/search.py
@@ -45,10 +45,11 @@
     # being parsed as logical query atoms.
     if term.lower() in ("and", "or", "not"):
         term = '"%s"' % term
-    return term
+    return quote_chars(term)
 
 
 def munge_search_term(query):
+    original_query = query
     for char in BAD_CHARS:
         query = query.replace(char, " ")
 
@@ -67,7 +68,7 @@
 
     r += map(quote, query.strip().split())
     r = " AND ".join(r)
-    r = quote_chars(r) + ("*" if r and not r.endswith('"') else "")
+    r = r + ("*" if r and not original_query.endswith('"') else "")
     return r

{"golden_diff": "diff --git a/Products/CMFPlone/browser/search.py b/Products/CMFPlone/browser/search.py\n--- a/Products/CMFPlone/browser/search.py\n+++ b/Products/CMFPlone/browser/search.py\n@@ -45,10 +45,11 @@\n     # being parsed as logical query atoms.\n     if term.lower() in (\"and\", \"or\", \"not\"):\n         term = '\"%s\"' % term\n-    return term\n+    return quote_chars(term)\n \n \n def munge_search_term(query):\n+    original_query = query\n     for char in BAD_CHARS:\n         query = query.replace(char, \" \")\n \n@@ -67,7 +68,7 @@\n \n     r += map(quote, query.strip().split())\n     r = \" AND \".join(r)\n-    r = quote_chars(r) + (\"*\" if r and not r.endswith('\"') else \"\")\n+    r = r + (\"*\" if r and not original_query.endswith('\"') else \"\")\n     return r\n", "issue": "Catalog search does not handle correctly parenthesis inside quotes\n## BUG/PROBLEM REPORT (OR OTHER COMMON ISSUE)\r\n\r\n### What I did:\r\n\r\n- Add a page with parenthesis enclosed text as a title\r\n- Try to search that page using quoted query\r\n\r\n### What I expect to happen:\r\n\r\n- The search searches the page\r\n\r\n### What actually happened:\r\n\r\n- The search does not find the page\r\n- When accessing through REST API (because plone.app.querystring uses the `munge_search_term` function here), we get an error because the query is transformed to an unknown string for ZCatalog\r\n\r\n### What version of Plone/ Addons I am using:\r\n\r\n- Plone 6\r\n\n", "before_files": [{"content": "from DateTime import DateTime\nfrom plone.app.contentlisting.interfaces import IContentListing\nfrom plone.app.layout.navigation.interfaces import INavigationRoot\nfrom plone.base.batch import Batch\nfrom plone.base.interfaces import ISearchSchema\nfrom plone.base.interfaces.siteroot import IPloneSiteRoot\nfrom plone.registry.interfaces import IRegistry\nfrom Products.CMFCore.utils import getToolByName\nfrom Products.CMFPlone.browser.navtree import getNavigationRoot\nfrom Products.ZCTextIndex.ParseTree import ParseError\nfrom zope.cachedescriptors.property import Lazy as lazy_property\nfrom zope.component import getMultiAdapter\nfrom zope.component import getUtility\nfrom zope.component import queryUtility\nfrom zope.i18nmessageid import MessageFactory\nfrom zope.publisher.browser import BrowserView\nfrom ZTUtils import make_query\n\nimport json\nimport re\n\n\n_ = MessageFactory(\"plone\")\n\n# We should accept both a simple space, unicode u'\\u0020 but also a\n# multi-space, so called 'waji-kankaku', unicode u'\\u3000'\nMULTISPACE = \"\\u3000\"\nBAD_CHARS = (\"?\", \"-\", \"+\", \"*\", MULTISPACE)\nEVER = DateTime(\"1970-01-03\")\n\n\ndef quote_chars(s):\n    # We need to quote parentheses when searching text indices\n    if \"(\" in s:\n        s = s.replace(\"(\", '\"(\"')\n    if \")\" in s:\n        s = s.replace(\")\", '\")\"')\n    if MULTISPACE in s:\n        s = s.replace(MULTISPACE, \" \")\n    return s\n\n\ndef quote(term):\n    # The terms and, or and not must be wrapped in quotes to avoid\n    # being parsed as logical query atoms.\n    if term.lower() in (\"and\", \"or\", \"not\"):\n        term = '\"%s\"' % term\n    return term\n\n\ndef munge_search_term(query):\n    for char in BAD_CHARS:\n        query = query.replace(char, \" \")\n\n    # extract quoted phrases first\n    quoted_phrases = re.findall(r'\"([^\"]*)\"', query)\n    r = []\n    for qp in quoted_phrases:\n        # remove from original query\n        query = query.replace(f'\"{qp}\"', \"\")\n        # replace with cleaned leading/trailing whitespaces\n        # and skip empty phrases\n        clean_qp = qp.strip()\n        if not clean_qp:\n            continue\n        r.append(f'\"{clean_qp}\"')\n\n    r += map(quote, query.strip().split())\n    r = \" AND \".join(r)\n    r = quote_chars(r) + (\"*\" if r and not r.endswith('\"') else \"\")\n    return r\n\n\nclass Search(BrowserView):\n    valid_keys = (\"sort_on\", \"sort_order\", \"sort_limit\", \"fq\", \"fl\", \"facet\")\n\n    def results(\n        self, query=None, batch=True, b_size=10, b_start=0, use_content_listing=True\n    ):\n        \"\"\"Get properly wrapped search results from the catalog.\n        Everything in Plone that performs searches should go through this view.\n        'query' should be a dictionary of catalog parameters.\n        \"\"\"\n        if query is None:\n            query = {}\n        if batch:\n            query[\"b_start\"] = b_start = int(b_start)\n            query[\"b_size\"] = b_size\n        query = self.filter_query(query)\n\n        if query is None:\n            results = []\n        else:\n            catalog = getToolByName(self.context, \"portal_catalog\")\n            try:\n                results = catalog(**query)\n            except ParseError:\n                return []\n\n        if use_content_listing:\n            results = IContentListing(results)\n        if batch:\n            results = Batch(results, b_size, b_start)\n        return results\n\n    def _filter_query(self, query):\n        request = self.request\n\n        catalog = getToolByName(self.context, \"portal_catalog\")\n        valid_indexes = tuple(catalog.indexes())\n        valid_keys = self.valid_keys + valid_indexes\n\n        text = query.get(\"SearchableText\", None)\n        if text is None:\n            text = request.form.get(\"SearchableText\", \"\")\n        if not text:\n            # Without text, must provide a meaningful non-empty search\n            valid = set(valid_indexes).intersection(request.form.keys()) or set(\n                valid_indexes\n            ).intersection(query.keys())\n            if not valid:\n                return\n\n        for k, v in request.form.items():\n            if v and ((k in valid_keys) or k.startswith(\"facet.\")):\n                query[k] = v\n        if text:\n            query[\"SearchableText\"] = munge_search_term(text)\n\n        # don't filter on created at all if we want all results\n        created = query.get(\"created\")\n        if created:\n            try:\n                if created.get(\"query\", EVER) <= EVER:\n                    del query[\"created\"]\n            except AttributeError:\n                # created not a mapping\n                del query[\"created\"]\n\n        # respect `types_not_searched` setting\n        types = query.get(\"portal_type\", [])\n        if \"query\" in types:\n            types = types[\"query\"]\n        query[\"portal_type\"] = self.filter_types(types)\n        # respect effective/expiration date\n        query[\"show_inactive\"] = False\n        # respect navigation root if we're not at the site root.\n        if \"path\" not in query and not IPloneSiteRoot.providedBy(self.context):\n            query[\"path\"] = getNavigationRoot(self.context)\n\n        if \"sort_order\" in query and not query[\"sort_order\"]:\n            del query[\"sort_order\"]\n        return query\n\n    @lazy_property\n    def default_sort_on(self):\n        registry = getUtility(IRegistry)\n        search_settings = registry.forInterface(ISearchSchema, prefix=\"plone\")\n        return search_settings.sort_on\n\n    def filter_query(self, query):\n        query = self._filter_query(query)\n        if query is None:\n            query = {}\n        # explicitly set a sort; if no `sort_on` is present, the catalog sorts\n        # by relevance\n        if \"sort_on\" not in query:\n            self.default_sort_on\n            if self.default_sort_on != \"relevance\":\n                query[\"sort_on\"] = self.default_sort_on\n        elif query[\"sort_on\"] == \"relevance\":\n            del query[\"sort_on\"]\n        if query.get(\"sort_on\", \"\") == \"Date\":\n            query[\"sort_order\"] = \"reverse\"\n        elif \"sort_order\" in query:\n            del query[\"sort_order\"]\n        if not query:\n            return None\n        return query\n\n    def filter_types(self, types):\n        plone_utils = getToolByName(self.context, \"plone_utils\")\n        if not isinstance(types, list):\n            types = [types]\n        return plone_utils.getUserFriendlyTypes(types)\n\n    def types_list(self):\n        # only show those types that have any content\n        catalog = getToolByName(self.context, \"portal_catalog\")\n        used_types = catalog._catalog.getIndex(\"portal_type\").uniqueValues()\n        return self.filter_types(list(used_types))\n\n    def sort_options(self):\n        \"\"\"Sorting options for search results view.\"\"\"\n        if \"sort_on\" not in self.request.form:\n            self.request.form[\"sort_on\"] = self.default_sort_on\n        return (\n            SortOption(self.request, _(\"relevance\"), \"relevance\"),\n            SortOption(self.request, _(\"date (newest first)\"), \"Date\", reverse=True),\n            SortOption(self.request, _(\"alphabetically\"), \"sortable_title\"),\n        )\n\n    def show_advanced_search(self):\n        \"\"\"Whether we need to show advanced search options a.k.a. filters?\"\"\"\n        show = self.request.get(\"advanced_search\", None)\n        if not show or show == \"False\":\n            return False\n        return True\n\n    def advanced_search_trigger(self):\n        \"\"\"URL builder for show/close advanced search filters.\"\"\"\n        query = self.request.get(\"QUERY_STRING\", None)\n        url = self.request.get(\"ACTUAL_URL\", self.context.absolute_url())\n        if not query:\n            return url\n        if \"advanced_search\" in query:\n            if \"advanced_search=True\" in query:\n                query = query.replace(\"advanced_search=True\", \"\")\n            if \"advanced_search=False\" in query:\n                query = query.replace(\"advanced_search=False\", \"\")\n        else:\n            query = query + \"&advanced_search=True\"\n        return url + \"?\" + query\n\n    def breadcrumbs(self, item):\n        obj = item.getObject()\n        view = getMultiAdapter((obj, self.request), name=\"breadcrumbs_view\")\n        # cut off the item itself\n        breadcrumbs = list(view.breadcrumbs())[:-1]\n        if len(breadcrumbs) == 0:\n            # don't show breadcrumbs if we only have a single element\n            return None\n        if len(breadcrumbs) > 3:\n            # if we have too long breadcrumbs, emit the middle elements\n            empty = {\"absolute_url\": \"\", \"Title\": \"\u2026\"}\n            breadcrumbs = [breadcrumbs[0], empty] + breadcrumbs[-2:]\n        return breadcrumbs\n\n    def navroot_url(self):\n        if not hasattr(self, \"_navroot_url\"):\n            state = self.context.unrestrictedTraverse(\"@@plone_portal_state\")\n            self._navroot_url = state.navigation_root_url()\n        return self._navroot_url\n\n    @property\n    def show_images(self):\n        registry = queryUtility(IRegistry)\n        return registry.get(\"plone.search_show_images\")\n\n    @property\n    def search_image_scale(self):\n        registry = queryUtility(IRegistry)\n        return registry.get(\"plone.search_image_scale\")\n\n\nclass AjaxSearch(Search):\n    def __call__(self):\n        items = []\n        try:\n            per_page = int(self.request.form.get(\"perPage\"))\n        except Exception:\n            per_page = 10\n        try:\n            page = int(self.request.form.get(\"page\"))\n        except Exception:\n            page = 1\n\n        results = self.results(batch=False, use_content_listing=False)\n        batch = Batch(results, per_page, start=(page - 1) * per_page)\n\n        registry = queryUtility(IRegistry)\n        length = registry.get(\"plone.search_results_description_length\")\n        show_images = registry.get(\"plone.search_show_images\")\n        if show_images:\n            image_scale = registry.get(\"plone.search_image_scale\")\n            # image_scaling = getMultiAdapter((self.context, self.request), name='image_scale')\n            self.image_scaling = getMultiAdapter(\n                (INavigationRoot(self.context), self.request), name=\"image_scale\"\n            )\n        plone_view = getMultiAdapter((self.context, self.request), name=\"plone\")\n        view_action_types = registry.get(\"plone.types_use_view_action_in_listings\", [])\n        for item in batch:\n            url = item.getURL()\n            if item.portal_type in view_action_types:\n                url = \"%s/view\" % url\n            img_tag = None\n            if show_images:\n                img_tag = self.get_image_tag(item, image_scale)\n            items.append(\n                {\n                    \"id\": item.UID,\n                    \"title\": item.Title,\n                    \"description\": plone_view.cropText(item.Description, length),\n                    \"url\": url,\n                    \"state\": item.review_state if item.review_state else None,\n                    \"img_tag\": img_tag,\n                }\n            )\n        return json.dumps({\"total\": len(results), \"items\": items})\n\n    def get_image_tag(self, item, image_scale):\n        return self.image_scaling.tag(item, \"image\", scale=image_scale)\n\n\nclass SortOption:\n    def __init__(self, request, title, sortkey=\"\", reverse=False):\n        self.request = request\n        self.title = title\n        self.sortkey = sortkey\n        self.reverse = reverse\n\n    def selected(self):\n        sort_on = self.request.get(\"sort_on\", \"\")\n        return sort_on == self.sortkey and sort_on != \"\"\n\n    def url(self):\n        q = {}\n        q.update(self.request.form)\n        if \"sort_on\" in q.keys():\n            del q[\"sort_on\"]\n        if \"sort_order\" in q.keys():\n            del q[\"sort_order\"]\n        q[\"sort_on\"] = self.sortkey\n        if self.reverse:\n            q[\"sort_order\"] = \"reverse\"\n\n        base_url = self.request.URL\n        return base_url + \"?\" + make_query(q)\n", "path": "Products/CMFPlone/browser/search.py"}], "after_files": [{"content": "from DateTime import DateTime\nfrom plone.app.contentlisting.interfaces import IContentListing\nfrom plone.app.layout.navigation.interfaces import INavigationRoot\nfrom plone.base.batch import Batch\nfrom plone.base.interfaces import ISearchSchema\nfrom plone.base.interfaces.siteroot import IPloneSiteRoot\nfrom plone.registry.interfaces import IRegistry\nfrom Products.CMFCore.utils import getToolByName\nfrom Products.CMFPlone.browser.navtree import getNavigationRoot\nfrom Products.ZCTextIndex.ParseTree import ParseError\nfrom zope.cachedescriptors.property import Lazy as lazy_property\nfrom zope.component import getMultiAdapter\nfrom zope.component import getUtility\nfrom zope.component import queryUtility\nfrom zope.i18nmessageid import MessageFactory\nfrom zope.publisher.browser import BrowserView\nfrom ZTUtils import make_query\n\nimport json\nimport re\n\n\n_ = MessageFactory(\"plone\")\n\n# We should accept both a simple space, unicode u'\\u0020 but also a\n# multi-space, so called 'waji-kankaku', unicode u'\\u3000'\nMULTISPACE = \"\\u3000\"\nBAD_CHARS = (\"?\", \"-\", \"+\", \"*\", MULTISPACE)\nEVER = DateTime(\"1970-01-03\")\n\n\ndef quote_chars(s):\n    # We need to quote parentheses when searching text indices\n    if \"(\" in s:\n        s = s.replace(\"(\", '\"(\"')\n    if \")\" in s:\n        s = s.replace(\")\", '\")\"')\n    if MULTISPACE in s:\n        s = s.replace(MULTISPACE, \" \")\n    return s\n\n\ndef quote(term):\n    # The terms and, or and not must be wrapped in quotes to avoid\n    # being parsed as logical query atoms.\n    if term.lower() in (\"and\", \"or\", \"not\"):\n        term = '\"%s\"' % term\n    return quote_chars(term)\n\n\ndef munge_search_term(query):\n    original_query = query\n    for char in BAD_CHARS:\n        query = query.replace(char, \" \")\n\n    # extract quoted phrases first\n    quoted_phrases = re.findall(r'\"([^\"]*)\"', query)\n    r = []\n    for qp in quoted_phrases:\n        # remove from original query\n        query = query.replace(f'\"{qp}\"', \"\")\n        # replace with cleaned leading/trailing whitespaces\n        # and skip empty phrases\n        clean_qp = qp.strip()\n        if not clean_qp:\n            continue\n        r.append(f'\"{clean_qp}\"')\n\n    r += map(quote, query.strip().split())\n    r = \" AND \".join(r)\n    r = r + (\"*\" if r and not original_query.endswith('\"') else \"\")\n    return r\n\n\nclass Search(BrowserView):\n    valid_keys = (\"sort_on\", \"sort_order\", \"sort_limit\", \"fq\", \"fl\", \"facet\")\n\n    def results(\n        self, query=None, batch=True, b_size=10, b_start=0, use_content_listing=True\n    ):\n        \"\"\"Get properly wrapped search results from the catalog.\n        Everything in Plone that performs searches should go through this view.\n        'query' should be a dictionary of catalog parameters.\n        \"\"\"\n        if query is None:\n            query = {}\n        if batch:\n            query[\"b_start\"] = b_start = int(b_start)\n            query[\"b_size\"] = b_size\n        query = self.filter_query(query)\n\n        if query is None:\n            results = []\n        else:\n            catalog = getToolByName(self.context, \"portal_catalog\")\n            try:\n                results = catalog(**query)\n            except ParseError:\n                return []\n\n        if use_content_listing:\n            results = IContentListing(results)\n        if batch:\n            results = Batch(results, b_size, b_start)\n        return results\n\n    def _filter_query(self, query):\n        request = self.request\n\n        catalog = getToolByName(self.context, \"portal_catalog\")\n        valid_indexes = tuple(catalog.indexes())\n        valid_keys = self.valid_keys + valid_indexes\n\n        text = query.get(\"SearchableText\", None)\n        if text is None:\n            text = request.form.get(\"SearchableText\", \"\")\n        if not text:\n            # Without text, must provide a meaningful non-empty search\n            valid = set(valid_indexes).intersection(request.form.keys()) or set(\n                valid_indexes\n            ).intersection(query.keys())\n            if not valid:\n                return\n\n        for k, v in request.form.items():\n            if v and ((k in valid_keys) or k.startswith(\"facet.\")):\n                query[k] = v\n        if text:\n            query[\"SearchableText\"] = munge_search_term(text)\n\n        # don't filter on created at all if we want all results\n        created = query.get(\"created\")\n        if created:\n            try:\n                if created.get(\"query\", EVER) <= EVER:\n                    del query[\"created\"]\n            except AttributeError:\n                # created not a mapping\n                del query[\"created\"]\n\n        # respect `types_not_searched` setting\n        types = query.get(\"portal_type\", [])\n        if \"query\" in types:\n            types = types[\"query\"]\n        query[\"portal_type\"] = self.filter_types(types)\n        # respect effective/expiration date\n        query[\"show_inactive\"] = False\n        # respect navigation root if we're not at the site root.\n        if \"path\" not in query and not IPloneSiteRoot.providedBy(self.context):\n            query[\"path\"] = getNavigationRoot(self.context)\n\n        if \"sort_order\" in query and not query[\"sort_order\"]:\n            del query[\"sort_order\"]\n        return query\n\n    @lazy_property\n    def default_sort_on(self):\n        registry = getUtility(IRegistry)\n        search_settings = registry.forInterface(ISearchSchema, prefix=\"plone\")\n        return search_settings.sort_on\n\n    def filter_query(self, query):\n        query = self._filter_query(query)\n        if query is None:\n            query = {}\n        # explicitly set a sort; if no `sort_on` is present, the catalog sorts\n        # by relevance\n        if \"sort_on\" not in query:\n            self.default_sort_on\n            if self.default_sort_on != \"relevance\":\n                query[\"sort_on\"] = self.default_sort_on\n        elif query[\"sort_on\"] == \"relevance\":\n            del query[\"sort_on\"]\n        if query.get(\"sort_on\", \"\") == \"Date\":\n            query[\"sort_order\"] = \"reverse\"\n        elif \"sort_order\" in query:\n            del query[\"sort_order\"]\n        if not query:\n            return None\n        return query\n\n    def filter_types(self, types):\n        plone_utils = getToolByName(self.context, \"plone_utils\")\n        if not isinstance(types, list):\n            types = [types]\n        return plone_utils.getUserFriendlyTypes(types)\n\n    def types_list(self):\n        # only show those types that have any content\n        catalog = getToolByName(self.context, \"portal_catalog\")\n        used_types = catalog._catalog.getIndex(\"portal_type\").uniqueValues()\n        return self.filter_types(list(used_types))\n\n    def sort_options(self):\n        \"\"\"Sorting options for search results view.\"\"\"\n        if \"sort_on\" not in self.request.form:\n            self.request.form[\"sort_on\"] = self.default_sort_on\n        return (\n            SortOption(self.request, _(\"relevance\"), \"relevance\"),\n            SortOption(self.request, _(\"date (newest first)\"), \"Date\", reverse=True),\n            SortOption(self.request, _(\"alphabetically\"), \"sortable_title\"),\n        )\n\n    def show_advanced_search(self):\n        \"\"\"Whether we need to show advanced search options a.k.a. filters?\"\"\"\n        show = self.request.get(\"advanced_search\", None)\n        if not show or show == \"False\":\n            return False\n        return True\n\n    def advanced_search_trigger(self):\n        \"\"\"URL builder for show/close advanced search filters.\"\"\"\n        query = self.request.get(\"QUERY_STRING\", None)\n        url = self.request.get(\"ACTUAL_URL\", self.context.absolute_url())\n        if not query:\n            return url\n        if \"advanced_search\" in query:\n            if \"advanced_search=True\" in query:\n                query = query.replace(\"advanced_search=True\", \"\")\n            if \"advanced_search=False\" in query:\n                query = query.replace(\"advanced_search=False\", \"\")\n        else:\n            query = query + \"&advanced_search=True\"\n        return url + \"?\" + query\n\n    def breadcrumbs(self, item):\n        obj = item.getObject()\n        view = getMultiAdapter((obj, self.request), name=\"breadcrumbs_view\")\n        # cut off the item itself\n        breadcrumbs = list(view.breadcrumbs())[:-1]\n        if len(breadcrumbs) == 0:\n            # don't show breadcrumbs if we only have a single element\n            return None\n        if len(breadcrumbs) > 3:\n            # if we have too long breadcrumbs, emit the middle elements\n            empty = {\"absolute_url\": \"\", \"Title\": \"\u2026\"}\n            breadcrumbs = [breadcrumbs[0], empty] + breadcrumbs[-2:]\n        return breadcrumbs\n\n    def navroot_url(self):\n        if not hasattr(self, \"_navroot_url\"):\n            state = self.context.unrestrictedTraverse(\"@@plone_portal_state\")\n            self._navroot_url = state.navigation_root_url()\n        return self._navroot_url\n\n    @property\n    def show_images(self):\n        registry = queryUtility(IRegistry)\n        return registry.get(\"plone.search_show_images\")\n\n    @property\n    def search_image_scale(self):\n        registry = queryUtility(IRegistry)\n        return registry.get(\"plone.search_image_scale\")\n\n\nclass AjaxSearch(Search):\n    def __call__(self):\n        items = []\n        try:\n            per_page = int(self.request.form.get(\"perPage\"))\n        except Exception:\n            per_page = 10\n        try:\n            page = int(self.request.form.get(\"page\"))\n        except Exception:\n            page = 1\n\n        results = self.results(batch=False, use_content_listing=False)\n        batch = Batch(results, per_page, start=(page - 1) * per_page)\n\n        registry = queryUtility(IRegistry)\n        length = registry.get(\"plone.search_results_description_length\")\n        show_images = registry.get(\"plone.search_show_images\")\n        if show_images:\n            image_scale = registry.get(\"plone.search_image_scale\")\n            # image_scaling = getMultiAdapter((self.context, self.request), name='image_scale')\n            self.image_scaling = getMultiAdapter(\n                (INavigationRoot(self.context), self.request), name=\"image_scale\"\n            )\n        plone_view = getMultiAdapter((self.context, self.request), name=\"plone\")\n        view_action_types = registry.get(\"plone.types_use_view_action_in_listings\", [])\n        for item in batch:\n            url = item.getURL()\n            if item.portal_type in view_action_types:\n                url = \"%s/view\" % url\n            img_tag = None\n            if show_images:\n                img_tag = self.get_image_tag(item, image_scale)\n            items.append(\n                {\n                    \"id\": item.UID,\n                    \"title\": item.Title,\n                    \"description\": plone_view.cropText(item.Description, length),\n                    \"url\": url,\n                    \"state\": item.review_state if item.review_state else None,\n                    \"img_tag\": img_tag,\n                }\n            )\n        return json.dumps({\"total\": len(results), \"items\": items})\n\n    def get_image_tag(self, item, image_scale):\n        return self.image_scaling.tag(item, \"image\", scale=image_scale)\n\n\nclass SortOption:\n    def __init__(self, request, title, sortkey=\"\", reverse=False):\n        self.request = request\n        self.title = title\n        self.sortkey = sortkey\n        self.reverse = reverse\n\n    def selected(self):\n        sort_on = self.request.get(\"sort_on\", \"\")\n        return sort_on == self.sortkey and sort_on != \"\"\n\n    def url(self):\n        q = {}\n        q.update(self.request.form)\n        if \"sort_on\" in q.keys():\n            del q[\"sort_on\"]\n        if \"sort_order\" in q.keys():\n            del q[\"sort_order\"]\n        q[\"sort_on\"] = self.sortkey\n        if self.reverse:\n            q[\"sort_order\"] = \"reverse\"\n\n        base_url = self.request.URL\n        return base_url + \"?\" + make_query(q)\n", "path": "Products/CMFPlone/browser/search.py"}]}