Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/moin/_tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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
158 changes: 96 additions & 62 deletions src/moin/apps/frontend/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<itemname:item_name>", 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
Expand All @@ -433,39 +450,35 @@ 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"
time_sorting = request.args.get("time_sorting")
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:
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/moin/static/css/common.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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; }
Expand Down
7 changes: 6 additions & 1 deletion src/moin/templates/ajaxsearch.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ <h4>{{ _("Please check these tickets if they cover your issue:") }}</h4>

{% else %}
{# when user keys changes into long search form, updates to whoosh_query indicates conclusion of queued searches #}
{% if subitem_target %}
<div class="moin-search-scope">
<em>Searching within {{ subitem_target }} and subitems for: “{{ query }}”</em>
</div>
{% endif %}
<p class="moin-suggestions"> {{ _("Whoosh query:") }}
<span class="moin-suggestion-terms">{{ whoosh_query }} </span>
</p>
Expand Down Expand Up @@ -97,7 +102,7 @@ <h4>{{ _("Please check these tickets if they cover your issue:") }}</h4>
{%- if result['tags'] %}
<p class="moin-found-text">{{ _("TAGS: {content}").format(content=result['tags']|safe) }}</p>
{%- endif %}
{%- if content and result.highlights('content') %}
{%- if result.highlights('content') %}
<p class="moin-found-text">{{ _("CONTENT: {content}").format(content=result.highlights('content')|safe) }}</p>
{%- endif %}
{%- else %}
Expand Down