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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to SearchMob Desktop are documented here. The version scheme

## [Unreleased]

### Fixed
- **Applying a scope or rule from the browser no longer throws away your results.** On a SearchMob
search page in your browser, changing the scope (or blocking/raising/lowering/pinning a result's
site) used to send you to the home page with an empty search box, losing the results you were
looking at. It now returns you to the same search with the new ranking applied, so you keep your
results instead of starting over.

## 26.06.05 — 2026-06-05

### Added
Expand Down
20 changes: 17 additions & 3 deletions src/searchmob_desktop/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import asdict, dataclass, replace
from ipaddress import ip_address
from urllib.parse import parse_qsl, urlsplit
from urllib.parse import parse_qsl, urlencode, urlsplit

from starlette.applications import Starlette
from starlette.middleware import Middleware
Expand Down Expand Up @@ -770,6 +770,20 @@ async def _form(request: Request) -> dict[str, str]:
raw = await request.body()
return dict(parse_qsl(raw.decode("utf-8", "replace")))

def _redirect_to_results(form: dict[str, str]) -> Response:
# Return to the results page a scope/rule POST came from, rebuilt from the hidden q/sort/
# vertical fields the served forms carry, so the new ranking is applied to the same search
# instead of dumping the owner on the home page. (Our no-referrer policy strips the Referer,
# so the Referer-based _redirect_back always fell through to "/".) No query - the home-page
# or settings scope selector - falls back to home. 303 -> re-fetch with GET.
query = form.get("q", "").strip()
if not query:
return RedirectResponse("/", status_code=303)
sort_mode = form.get("sort", "").strip() or "fresh"
vertical = form.get("vertical", "").strip() or "web"
target = "/search?" + urlencode({"q": query, "vertical": vertical, "sort": sort_mode})
return RedirectResponse(target, status_code=303)

async def set_domain_rule(request: Request) -> Response:
if ranking_rules_saver is None:
return PlainTextResponse("Personalization is read-only here.", status_code=503)
Expand All @@ -778,15 +792,15 @@ async def set_domain_rule(request: Request) -> Response:
action = form.get("action", "").strip().upper()
if domain and action in RankRule.__members__:
ranking_rules_saver(rules_provider().with_domain_rule(domain, RankRule[action]))
return _redirect_back(request)
return _redirect_to_results(form)

async def set_scope(request: Request) -> Response:
if ranking_rules_saver is None:
return PlainTextResponse("Personalization is read-only here.", status_code=503)
form = await _form(request)
lens = form.get("lens", "").strip()
ranking_rules_saver(rules_provider().with_active_lens(lens or None))
return _redirect_back(request)
return _redirect_to_results(form)

def _csv_tuple(raw: str) -> tuple[str, ...]:
# Split a comma- or newline-separated field into clean, de-duplicated, lowercased entries.
Expand Down
25 changes: 20 additions & 5 deletions src/searchmob_desktop/server/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,20 @@ def _home_scope(rules: RankingRules) -> tuple[str, str]:
return select, form


def _scope_bar(rules: RankingRules) -> str:
def _search_context_fields(query: str, sort_mode: str, vertical: str) -> str:
"""Hidden q/sort/vertical fields so a mutation POST can return to the same results page.

The served forms carry the current search this way because our ``Referrer-Policy: no-referrer``
strips the Referer, so the handler cannot recover the originating page from it.
"""
return (
f'<input type="hidden" name="q" value="{escape(query, quote=True)}">'
f'<input type="hidden" name="sort" value="{escape(sort_mode, quote=True)}">'
f'<input type="hidden" name="vertical" value="{escape(vertical, quote=True)}">'
)


def _scope_bar(rules: RankingRules, query: str, sort_mode: str, vertical: str) -> str:
"""A scope (lens) selector. Renders only when the profile has at least one lens defined."""
if not rules.lenses:
return ""
Expand All @@ -634,7 +647,8 @@ def _scope_bar(rules: RankingRules) -> str:
# onchange auto-submits when JS is on; the noscript button covers the JS-off case.
return (
'<form class="scopebar" action="/scope" method="post">'
f'<label for="sm-scope">{escape(tr("Scope"))}:</label>'
+ _search_context_fields(query, sort_mode, vertical)
+ f'<label for="sm-scope">{escape(tr("Scope"))}:</label>'
'<select id="sm-scope" name="lens" onchange="this.form.submit()">'
+ "".join(options)
+ "</select>"
Expand All @@ -643,7 +657,7 @@ def _scope_bar(rules: RankingRules) -> str:
)


def _rank_controls(url: str, rules: RankingRules) -> str:
def _rank_controls(url: str, rules: RankingRules, query: str, sort_mode: str, vertical: str) -> str:
"""Per-result domain controls (block / lower / raise / pin / reset) as a single POST form."""
domain = host_of_url(url)
if not domain:
Expand All @@ -652,6 +666,7 @@ def _rank_controls(url: str, rules: RankingRules) -> str:
safe_domain = escape(domain, quote=True)
parts = [
'<form class="rank" action="/rules/domain" method="post">',
_search_context_fields(query, sort_mode, vertical),
f'<span class="state">{escape(domain)}</span>',
f'<input type="hidden" name="domain" value="{safe_domain}">',
]
Expand Down Expand Up @@ -810,7 +825,7 @@ def render_results_page(
parts.append(_engine_status_line(engine_status))
parts.append(_sort_bar(query, sort_mode))
if editable:
parts.append(_scope_bar(active_rules))
parts.append(_scope_bar(active_rules, query, sort_mode, vertical))
for index, result in enumerate(results_list):
# Results past the first reveal window start collapsed; the reveal script unhides them
# in batches on scroll. The full list is still in the DOM, so click positions (and the
Expand Down Expand Up @@ -841,7 +856,7 @@ def render_results_page(
parts.append(f'<span class="chip">{escape(name)}</span>')
parts.append("</div>")
if editable:
parts.append(_rank_controls(result.url, active_rules))
parts.append(_rank_controls(result.url, active_rules, query, sort_mode, vertical))
parts.append("</div>")
if len(results_list) > _REVEAL_SIZE:
parts.append('<div class="reveal-sentinel" aria-hidden="true"></div>')
Expand Down
42 changes: 42 additions & 0 deletions tests/server/test_rules_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,48 @@ def test_post_scope_sets_active_lens() -> None:
assert holder.rules.active_lens is None


def test_mutation_redirects_back_to_the_results_page() -> None:
# Regression: applying a scope/rule from the results page must return to that search (carried
# via hidden q/sort/vertical fields), not dump the owner on the home page and lose the results.
# The Referer is stripped by our no-referrer policy, so the redirect cannot rely on it.
holder = _Holder(RankingRules(lenses=(Lens(name="Docs"),)))
with _loopback(_app(holder)) as client:
resp = client.post(
"/scope",
data={"lens": "Docs", "q": "privacy", "sort": "fresh", "vertical": "news"},
follow_redirects=False,
)
assert resp.status_code == 303
loc = resp.headers["location"]
assert loc.startswith("/search?")
assert "q=privacy" in loc
assert "vertical=news" in loc

resp = client.post(
"/rules/domain",
data={
"domain": "news.example",
"action": "BLOCK",
"q": "privacy",
"sort": "fresh",
"vertical": "web",
},
follow_redirects=False,
)
assert resp.status_code == 303
assert resp.headers["location"].startswith("/search?")
assert "q=privacy" in resp.headers["location"]


def test_mutation_without_a_query_falls_back_home() -> None:
# The home-page scope selector carries no query; it must still redirect to "/", not error.
holder = _Holder(RankingRules(lenses=(Lens(name="Docs"),)))
with _loopback(_app(holder)) as client:
resp = client.post("/scope", data={"lens": "Docs"}, follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/"


def test_endpoints_are_read_only_without_a_saver() -> None:
with _loopback(_app(None)) as client:
resp = client.post("/rules/domain", data={"domain": "x.example", "action": "BLOCK"})
Expand Down