diff --git a/src/moin/_tests/test_views.py b/src/moin/_tests/test_views.py new file mode 100644 index 000000000..a8ebe78fd --- /dev/null +++ b/src/moin/_tests/test_views.py @@ -0,0 +1,30 @@ +# Copyright: 2025 MoinMoin contributors +# License: GNU GPL v2 (or any later version), see LICENSE.txt for details. + +""" + MoinMoin - moin.views Tests +""" + +from moin.apps.frontend.views import parse_scoped_query + + +class TestParseScopedQuery: + def test_parse_scoped_query_with_prefix_and_query(self): + scope, actual_query = parse_scoped_query(">Lectures design patterns") + assert scope == "Lectures" + assert actual_query == "design patterns" + + def test_parse_scoped_query_with_prefix_only(self): + scope, actual_query = parse_scoped_query(">Lectures") + assert scope == "Lectures" + assert actual_query == "" + + def test_parse_scoped_query_without_prefix(self): + scope, actual_query = parse_scoped_query("design patterns") + assert scope is None + assert actual_query == "design patterns" + + def test_parse_scoped_query_empty(self): + scope, actual_query = parse_scoped_query("") + assert scope is None + assert actual_query is None diff --git a/src/moin/apps/frontend/views.py b/src/moin/apps/frontend/views.py index c77ca3946..cfcf2abae 100644 --- a/src/moin/apps/frontend/views.py +++ b/src/moin/apps/frontend/views.py @@ -410,13 +410,30 @@ def add_facets(facets, time_sorting): return facets +def parse_scoped_query(query): + """ + Parses a scoped query starting with '>' into (scope, actual_query). + For example, '>Lectures design patterns' becomes ('Lectures', 'design patterns'). + If no scope is found, returns (None, query). + """ + if not query: + return None, None + if query.startswith(">"): + parts = query[1:].split(None, 1) + if len(parts) == 2: + return parts[0], parts[1] + elif len(parts) == 1: + return parts[0], "" + return None, query + + @frontend.route("/+search/", methods=["GET", "POST"]) @frontend.route("/+search", defaults=dict(item_name=""), methods=["GET", "POST"]) def search(item_name): """ Perform a whoosh search of the index and display the matching items. - The default search is across all namespaces in the index and includes trash. + The default search is across all namespaces in the index. The Jinja template formatting the output may also display data related to the search such as the whoosh query, filter (if any), hit counts, and additional @@ -433,10 +450,6 @@ def search(item_name): valid = search_form.validate() time_sorting = False filetypes = [] - namespaces = [] - trash = request.args.get("trash", "false") - best_match = False - terms = [] if ajax: query = request.args.get("q") history = request.args.get("history") == "true" @@ -444,28 +457,28 @@ def search(item_name): if time_sorting == "default": time_sorting = False filetypes = request.args.get("filetypes") - namespaces = request.args.get("namespaces") is_ticket = bool(request.args.get("is_ticket")) - # remove the extra ',' at the end of the filetyes and namespaces strings if filetypes: - filetypes = filetypes.split(",")[:-1] - if namespaces: - namespaces = namespaces.split(",")[:-1] - namespaces = ["" if ns == NAMESPACE_UI_DEFAULT else ns for ns in namespaces] + filetypes = filetypes.split(",")[:-1] # To remove the extra '' at the end of the list else: - # not ajax, the form has only the search string q as keyed by the user - query = search_form["q"].value or "" - history = False # show only current revisionss - # redirect to best matched item if user keys leading \ in q string - if query.startswith("\\"): - best_match = True - query = query[1:] + query = search_form["q"].value + history = bool(request.values.get("history")) + + best_match = False + # we test for query in case this is a test run + if query and query.startswith("\\"): + best_match = True + query = query[1:] + + # detect prefix and extract target item + subitem_target, query = parse_scoped_query(query) if valid or ajax: # most fields in the schema use a StandardAnalyzer, it omits fairly frequently used words # this finds such words and reports to the user analyzer = StandardAnalyzer() omitted_words = [token.text for token in analyzer(query, removestops=False) if token.stopped] + idx_name = ALL_REVS if history else LATEST_REVS if best_match: @@ -475,39 +488,57 @@ def search(item_name): [NAMES, NAMENGRAM, TAGS, SUMMARY, SUMMARYNGRAM, CONTENT, CONTENTNGRAM, COMMENT], idx_name=idx_name ) q = qp.parse(query) - if trash == "false": - q = And([q, Not(Term(TRASH, True))]) - - if namespaces: - ns_terms = [Term(NAMESPACE, ns) for ns in namespaces] - q = And([q, Or(ns_terms)]) _filter = [] _filter = add_file_filters(_filter, filetypes) + + # if the user specified a subitem target + if subitem_target: + # if they also specified an item name from the URL + if item_name: + # display a note that the subitem will override the item + flash("Note: Subitem target in query overrides the item in the URL.") + # update the item_name to be the subitem_target + item_name = subitem_target + if item_name: # Only search this item and subitems - prefix_name = item_name + "/" - terms.append([Term(NAME_EXACT, item_name), Prefix(NAME_EXACT, prefix_name)]) - - show_transclusions = True - if show_transclusions: - # XXX Search subitems and all transcluded items (even recursively), - # still looks like a hack. Imaging you have "foo" on main page and - # "bar" on transcluded one. Then you search for "foo AND bar". - # Such stuff would only work if we expand transcluded items - # at indexing time (and we currently don't). - with flaskg.storage.indexer.ix[LATEST_REVS].searcher() as searcher: - subq = Or([Term(NAME_EXACT, item_name), Prefix(NAME_EXACT, prefix_name)]) - subq = And([subq, Every(ITEMTRANSCLUSIONS)]) - flaskg.clock.start("search subitems with transclusions") - results = searcher.search(subq, limit=None) - flaskg.clock.stop("search subitems with transclusions") - transcluded_names = set() - for hit in results: - name = hit[NAME] - transclusions = _compute_item_transclusions(name) - transcluded_names.update(transclusions) - # XXX Will whoosh cope with such a large filter query? - terms.append([Term(NAME_EXACT, tname) for tname in transcluded_names]) - _filter = Or(terms) + full_name = None + + # search for the full item name (i.e. "Home/Readings" for "Readings") + with flaskg.storage.indexer.ix[LATEST_REVS].searcher() as searcher: + all_items = searcher.search(Every(), limit=None) + for hit in all_items: + hit_name = hit[NAME][0] + if hit_name.endswith("/" + item_name) or hit_name == item_name: + full_name = hit_name + break + + if full_name: + # flash(f"Searching within {item_name} and its subitems for {query}.") + + prefix_name = full_name + "/" + terms = [Term(NAME_EXACT, full_name), Prefix(NAME_EXACT, prefix_name)] + + show_transclusions = True + if show_transclusions: + # XXX Search subitems and all transcluded items (even recursively), + # still looks like a hack. Imaging you have "foo" on main page and + # "bar" on transcluded one. Then you search for "foo AND bar". + # Such stuff would only work if we expand transcluded items + # at indexing time (and we currently don't). + with flaskg.storage.indexer.ix[LATEST_REVS].searcher() as searcher: + subq = Or([Term(NAME_EXACT, full_name), Prefix(NAME_EXACT, prefix_name)]) + subq = And([subq, Every(ITEMTRANSCLUSIONS)]) + flaskg.clock.start("search subitems with transclusions") + results = searcher.search(subq, limit=None) + flaskg.clock.stop("search subitems with transclusions") + transcluded_names = set() + for hit in results: + name = hit[NAME] + transclusions = _compute_item_transclusions(name) + transcluded_names.update(transclusions) + # XXX Will whoosh cope with such a large filter query? + terms.extend([Term(NAME_EXACT, tname) for tname in transcluded_names]) + _filter = Or(terms) with flaskg.storage.indexer.ix[idx_name].searcher() as searcher: # terms is set to retrieve list of terms which matched, in the searchtemplate, for highlight. @@ -540,6 +571,8 @@ def search(item_name): whoosh_query=q, whoosh_filter=_filter, flaskg=flaskg, + subitem_target=subitem_target, + query=query, ) else: html = render_template( @@ -553,6 +586,7 @@ def search(item_name): whoosh_query=q, whoosh_filter=_filter, flaskg=flaskg, + subitem_target=subitem_target, ) flaskg.clock.stop("search render") else: @@ -2019,8 +2053,7 @@ def subscribe_item(item_name): msg = _("You could not get subscribed to this item."), "error" if msg: flash(*msg) - next_url = request.referrer or url_for_item(item_name) - return redirect(next_url) + return redirect(url_for_item(item_name)) class ValidRegistration(Validator): @@ -2630,25 +2663,26 @@ class UserSettingsUIForm(Form): # validation failed response["flash"].append((_("Nothing saved."), "error")) - # if no flash message was added until here, we add a generic success message if not response["flash"]: + # if no flash message was added until here, we add a generic success message msg = _("Your changes have been saved.") response["flash"].append((msg, "info")) + repeat_flash_msg(msg, "info") + + if response["redirect"] is not None or not is_xhr: + # if we redirect or it is no XHR request, we just flash() the messages normally + for f in response["flash"]: + flash(*f) - # if it is a XHR request, render the part from the usersettings_ajax.html template - # and send the response encoded as an JSON object; - # the client side is responsible for displaying any flash messages if is_xhr: + # if it is a XHR request, render the part from the usersettings_ajax.html template + # and send the response encoded as an JSON object response["form"] = render_template("usersettings_ajax.html", part=part, form=form) return jsonify(**response) - - # if no XHR request, we just flash() the messages normally - for f in response["flash"]: - flash(*f) - - # if there is a redirect pending, use a normal HTTP redirect - if response["redirect"] is not None: - return redirect(response["redirect"]) + else: + # if it is not a XHR request but there is an redirect pending, we use a normal HTTP redirect + if response["redirect"] is not None: + return redirect(response["redirect"]) # if the view did not return until here, we add the current form to the forms dict # and continue with rendering the normal template diff --git a/src/moin/static/css/common.css b/src/moin/static/css/common.css index b678444bc..ccfd0ce0c 100644 --- a/src/moin/static/css/common.css +++ b/src/moin/static/css/common.css @@ -274,7 +274,9 @@ a.moin-item-overlay-lr:hover { opacity: .8; background-color: var(--bg-trans-hov #moin-long-searchform div { margin: 0; } #moin-long-searchform .moin-search-query { width: 90%; margin-left: 0; } .moin-search-option-bar { font-size: 1.25em; font-weight: bold; cursor: pointer; color: var(--link); } -.moin-search-options-table td { vertical-align: top; } +.moin-searchopt-tab > th:nth-child(1), +.moin-searchopt-tab > th:nth-child(2) { width: 20%; } +.moin-searchopt-tab > th:nth-child(3) { width: 60%; } .moin-search-hit-info { display: inline; } .moin-search-hits { font-weight: bold; } .moin-search-results { font-size: .92em; } @@ -283,6 +285,7 @@ a.moin-item-overlay-lr:hover { opacity: .8; background-color: var(--bg-trans-hov .moin-suggestion-terms { font-size: .92em; font-weight: normal; color: var(--muted); } .moin-search-name { font-weight: bold; font-size: 1.25em; padding-right: 1em; } .moin-search-match { margin-top: 1.5em; margin-bottom: .2em; } +.moin-search-scope { margin-bottom: 1em; font-size: 0.95em; color: #555; } /* misc moin css keywords */ .moin-wordbreak { word-break: break-all; word-wrap: break-word; } diff --git a/src/moin/templates/ajaxsearch.html b/src/moin/templates/ajaxsearch.html index 28baec456..209552378 100644 --- a/src/moin/templates/ajaxsearch.html +++ b/src/moin/templates/ajaxsearch.html @@ -31,6 +31,11 @@

{{ _("Please check these tickets if they cover your issue:") }}

{% else %} {# when user keys changes into long search form, updates to whoosh_query indicates conclusion of queued searches #} + {% if subitem_target %} +
+ Searching within {{ subitem_target }} and subitems for: “{{ query }}” +
+ {% endif %}

{{ _("Whoosh query:") }} {{ whoosh_query }}

@@ -97,7 +102,7 @@

{{ _("Please check these tickets if they cover your issue:") }}

{%- if result['tags'] %}

{{ _("TAGS: {content}").format(content=result['tags']|safe) }}

{%- endif %} - {%- if content and result.highlights('content') %} + {%- if result.highlights('content') %}

{{ _("CONTENT: {content}").format(content=result.highlights('content')|safe) }}

{%- endif %} {%- else %}