diff --git a/code/functions/search-query/api/java/nu/marginalia/api/searchquery/QueryProtobufCodec.java b/code/functions/search-query/api/java/nu/marginalia/api/searchquery/QueryProtobufCodec.java index e6e62dc3..0c2d1ff1 100644 --- a/code/functions/search-query/api/java/nu/marginalia/api/searchquery/QueryProtobufCodec.java +++ b/code/functions/search-query/api/java/nu/marginalia/api/searchquery/QueryProtobufCodec.java @@ -99,7 +99,8 @@ public class QueryProtobufCodec { IndexProtobufCodec.convertQueryLimits(request.getQueryLimits()), request.getSearchSetIdentifier(), QueryStrategy.valueOf(request.getQueryStrategy()), - ResultRankingParameters.TemporalBias.valueOf(request.getTemporalBias().getBias().name()) + ResultRankingParameters.TemporalBias.valueOf(request.getTemporalBias().getBias().name()), + request.getPagination().getPage() ); } @@ -107,14 +108,22 @@ public class QueryProtobufCodec { public static QueryResponse convertQueryResponse(RpcQsResponse query) { var results = new ArrayList(query.getResultsCount()); - for (int i = 0; i < query.getResultsCount(); i++) + for (int i = 0; i < query.getResultsCount(); i++) { results.add(convertDecoratedResult(query.getResults(i))); + } + + var requestPagination = query.getPagination(); + int totalResults = requestPagination.getTotalResults(); + int pageSize = requestPagination.getPageSize(); + int totalPages = (totalResults + pageSize - 1) / pageSize; return new QueryResponse( convertSearchSpecification(query.getSpecs()), results, query.getSearchTermsHumanList(), query.getProblemsList(), + query.getPagination().getPage(), + totalPages, query.getDomain() ); } @@ -304,6 +313,10 @@ public class QueryProtobufCodec { .setQueryStrategy(params.queryStrategy().name()) .setTemporalBias(RpcTemporalBias.newBuilder() .setBias(RpcTemporalBias.Bias.valueOf(params.temporalBias().name())) + .build()) + .setPagination(RpcQsQueryPagination.newBuilder() + .setPage(params.page()) + .setPageSize(Math.min(100, params.limits().resultsTotal())) .build()); if (params.nearDomain() != null) diff --git a/code/functions/search-query/api/java/nu/marginalia/api/searchquery/model/query/QueryParams.java b/code/functions/search-query/api/java/nu/marginalia/api/searchquery/model/query/QueryParams.java index 176b977e..d5bf4a17 100644 --- a/code/functions/search-query/api/java/nu/marginalia/api/searchquery/model/query/QueryParams.java +++ b/code/functions/search-query/api/java/nu/marginalia/api/searchquery/model/query/QueryParams.java @@ -4,6 +4,7 @@ import nu.marginalia.api.searchquery.model.results.ResultRankingParameters; import nu.marginalia.index.query.limit.QueryLimits; import nu.marginalia.index.query.limit.QueryStrategy; import nu.marginalia.index.query.limit.SpecificationLimit; + import javax.annotation.Nullable; import java.util.List; @@ -23,7 +24,8 @@ public record QueryParams( QueryLimits limits, String identifier, QueryStrategy queryStrategy, - ResultRankingParameters.TemporalBias temporalBias + ResultRankingParameters.TemporalBias temporalBias, + int page ) { public QueryParams(String query, QueryLimits limits, String identifier) { @@ -40,7 +42,8 @@ public record QueryParams( limits, identifier, QueryStrategy.AUTO, - ResultRankingParameters.TemporalBias.NONE + ResultRankingParameters.TemporalBias.NONE, + 1 // page ); } } diff --git a/code/functions/search-query/api/java/nu/marginalia/api/searchquery/model/query/QueryResponse.java b/code/functions/search-query/api/java/nu/marginalia/api/searchquery/model/query/QueryResponse.java index 217fe6cf..d9b13dbd 100644 --- a/code/functions/search-query/api/java/nu/marginalia/api/searchquery/model/query/QueryResponse.java +++ b/code/functions/search-query/api/java/nu/marginalia/api/searchquery/model/query/QueryResponse.java @@ -11,6 +11,8 @@ public record QueryResponse(SearchSpecification specs, List results, List searchTermsHuman, List problems, + int currentPage, + int totalPages, @Nullable String domain) { public Set getAllKeywords() { diff --git a/code/functions/search-query/java/nu/marginalia/functions/searchquery/QueryGRPCService.java b/code/functions/search-query/java/nu/marginalia/functions/searchquery/QueryGRPCService.java index 41951a3c..73d919bf 100644 --- a/code/functions/search-query/java/nu/marginalia/functions/searchquery/QueryGRPCService.java +++ b/code/functions/search-query/java/nu/marginalia/functions/searchquery/QueryGRPCService.java @@ -49,6 +49,7 @@ public class QueryGRPCService extends QueryApiGrpc.QueryApiImplBase { .labels(Integer.toString(request.getQueryLimits().getTimeoutMs()), Integer.toString(request.getQueryLimits().getResultsTotal())) .time(() -> { + var params = QueryProtobufCodec.convertRequest(request); var query = queryFactory.createQuery(params, ResultRankingParameters.sensibleDefaults()); @@ -68,7 +69,8 @@ public class QueryGRPCService extends QueryApiGrpc.QueryApiImplBase { .addAllResults(response.results()) .setPagination( RpcQsResultPagination.newBuilder() - .setPage(response.page()) + .setPage(requestPagination.getPage()) + .setPageSize(requestPagination.getPageSize()) .setTotalResults(response.totalResults()) ) .setSpecs(indexRequest) diff --git a/code/services-application/search-service/java/nu/marginalia/search/SearchOperator.java b/code/services-application/search-service/java/nu/marginalia/search/SearchOperator.java index 80c6aa9f..c36fa859 100644 --- a/code/services-application/search-service/java/nu/marginalia/search/SearchOperator.java +++ b/code/services-application/search-service/java/nu/marginalia/search/SearchOperator.java @@ -37,6 +37,7 @@ import java.util.Set; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import java.util.stream.IntStream; @Singleton public class SearchOperator { @@ -132,6 +133,14 @@ public class SearchOperator { List problems = getProblems(evalResult, queryResults, queryResponse); + List resultPages = IntStream.rangeClosed(1, queryResponse.totalPages()) + .mapToObj(number -> new DecoratedSearchResults.Page( + number, + number == userParams.page(), + userParams.withPage(number).renderUrl(websiteUrl) + )) + .toList(); + // Return the results to the user return DecoratedSearchResults.builder() .params(userParams) @@ -141,6 +150,7 @@ public class SearchOperator { .filters(new SearchFilters(websiteUrl, userParams)) .focusDomain(focusDomain) .focusDomainId(focusDomainId) + .resultPages(resultPages) .build(); } diff --git a/code/services-application/search-service/java/nu/marginalia/search/SearchQueryParamFactory.java b/code/services-application/search-service/java/nu/marginalia/search/SearchQueryParamFactory.java index 9fd94e63..6852423a 100644 --- a/code/services-application/search-service/java/nu/marginalia/search/SearchQueryParamFactory.java +++ b/code/services-application/search-service/java/nu/marginalia/search/SearchQueryParamFactory.java @@ -36,7 +36,8 @@ public class SearchQueryParamFactory { new QueryLimits(5, 100, 200, 8192), profile.searchSetIdentifier.name(), userParams.strategy(), - userParams.temporalBias() + userParams.temporalBias(), + userParams.page() ); } @@ -56,7 +57,8 @@ public class SearchQueryParamFactory { new QueryLimits(count, count, 100, 512), SearchSetIdentifier.NONE.name(), QueryStrategy.AUTO, - ResultRankingParameters.TemporalBias.NONE + ResultRankingParameters.TemporalBias.NONE, + 1 ); } @@ -75,7 +77,8 @@ public class SearchQueryParamFactory { new QueryLimits(100, 100, 100, 512), SearchSetIdentifier.NONE.name(), QueryStrategy.AUTO, - ResultRankingParameters.TemporalBias.NONE + ResultRankingParameters.TemporalBias.NONE, + 1 ); } @@ -94,7 +97,8 @@ public class SearchQueryParamFactory { new QueryLimits(100, 100, 100, 512), SearchSetIdentifier.NONE.name(), QueryStrategy.AUTO, - ResultRankingParameters.TemporalBias.NONE + ResultRankingParameters.TemporalBias.NONE, + 1 ); } } diff --git a/code/services-application/search-service/java/nu/marginalia/search/command/SearchParameters.java b/code/services-application/search-service/java/nu/marginalia/search/command/SearchParameters.java index 6fbc14e1..c10d0092 100644 --- a/code/services-application/search-service/java/nu/marginalia/search/command/SearchParameters.java +++ b/code/services-application/search-service/java/nu/marginalia/search/command/SearchParameters.java @@ -5,9 +5,11 @@ import nu.marginalia.api.searchquery.model.results.ResultRankingParameters; import nu.marginalia.index.query.limit.QueryStrategy; import nu.marginalia.index.query.limit.SpecificationLimit; import nu.marginalia.search.model.SearchProfile; +import spark.Request; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.Objects; import static nu.marginalia.search.command.SearchRecentParameter.RECENT; @@ -17,40 +19,60 @@ public record SearchParameters(String query, SearchRecentParameter recent, SearchTitleParameter searchTitle, SearchAdtechParameter adtech, - boolean poisonResults, - boolean newFilter + boolean newFilter, + int page ) { + + public SearchParameters(String queryString, Request request) { + this( + queryString, + SearchProfile.getSearchProfile(request.queryParams("profile")), + SearchJsParameter.parse(request.queryParams("js")), + SearchRecentParameter.parse(request.queryParams("recent")), + SearchTitleParameter.parse(request.queryParams("searchTitle")), + SearchAdtechParameter.parse(request.queryParams("adtech")), + "true".equals(request.queryParams("newfilter")), + Integer.parseInt(Objects.requireNonNullElse(request.queryParams("page"), "1")) + ); + } + public String profileStr() { return profile.filterId; } public SearchParameters withProfile(SearchProfile profile) { - return new SearchParameters(query, profile, js, recent, searchTitle, adtech, poisonResults, true); + return new SearchParameters(query, profile, js, recent, searchTitle, adtech, true, page); } public SearchParameters withJs(SearchJsParameter js) { - return new SearchParameters(query, profile, js, recent, searchTitle, adtech, poisonResults, true); + return new SearchParameters(query, profile, js, recent, searchTitle, adtech, true, page); } public SearchParameters withAdtech(SearchAdtechParameter adtech) { - return new SearchParameters(query, profile, js, recent, searchTitle, adtech, poisonResults, true); + return new SearchParameters(query, profile, js, recent, searchTitle, adtech, true, page); } public SearchParameters withRecent(SearchRecentParameter recent) { - return new SearchParameters(query, profile, js, recent, searchTitle, adtech, poisonResults, true); + return new SearchParameters(query, profile, js, recent, searchTitle, adtech, true, page); } public SearchParameters withTitle(SearchTitleParameter title) { - return new SearchParameters(query, profile, js, recent, title, adtech, poisonResults, true); + return new SearchParameters(query, profile, js, recent, title, adtech, true, page); + } + + public SearchParameters withPage(int page) { + return new SearchParameters(query, profile, js, recent, searchTitle, adtech, false, page); } public String renderUrl(WebsiteUrl baseUrl) { - String path = String.format("/search?query=%s&profile=%s&js=%s&adtech=%s&recent=%s&searchTitle=%s&newfilter=true", + String path = String.format("/search?query=%s&profile=%s&js=%s&adtech=%s&recent=%s&searchTitle=%s&newfilter=%s&page=%d", URLEncoder.encode(query, StandardCharsets.UTF_8), URLEncoder.encode(profile.filterId, StandardCharsets.UTF_8), URLEncoder.encode(js.value, StandardCharsets.UTF_8), URLEncoder.encode(adtech.value, StandardCharsets.UTF_8), URLEncoder.encode(recent.value, StandardCharsets.UTF_8), - URLEncoder.encode(searchTitle.value, StandardCharsets.UTF_8) + URLEncoder.encode(searchTitle.value, StandardCharsets.UTF_8), + Boolean.valueOf(newFilter).toString(), + page ); return baseUrl.withPath(path); diff --git a/code/services-application/search-service/java/nu/marginalia/search/model/DecoratedSearchResults.java b/code/services-application/search-service/java/nu/marginalia/search/model/DecoratedSearchResults.java index bdb46e34..4a641ad5 100644 --- a/code/services-application/search-service/java/nu/marginalia/search/model/DecoratedSearchResults.java +++ b/code/services-application/search-service/java/nu/marginalia/search/model/DecoratedSearchResults.java @@ -25,6 +25,10 @@ public class DecoratedSearchResults { private final int focusDomainId; private final SearchFilters filters; + private final List resultPages; + + public record Page(int number, boolean current, String href) {} + // These are used by the search form, they look unused in the IDE but are used by the mustache template, // DO NOT REMOVE THEM public int getResultCount() { return results.size(); } @@ -34,5 +38,7 @@ public class DecoratedSearchResults { public String getAdtech() { return params.adtech().value; } public String getRecent() { return params.recent().value; } public String getSearchTitle() { return params.searchTitle().value; } + public int page() { return params.page(); } public Boolean isNewFilter() { return params.newFilter(); } + } diff --git a/code/services-application/search-service/java/nu/marginalia/search/svc/SearchQueryService.java b/code/services-application/search-service/java/nu/marginalia/search/svc/SearchQueryService.java index b8bb908b..4a0b037e 100644 --- a/code/services-application/search-service/java/nu/marginalia/search/svc/SearchQueryService.java +++ b/code/services-application/search-service/java/nu/marginalia/search/svc/SearchQueryService.java @@ -3,8 +3,8 @@ package nu.marginalia.search.svc; import com.google.inject.Inject; import lombok.SneakyThrows; import nu.marginalia.WebsiteUrl; -import nu.marginalia.search.command.*; -import nu.marginalia.search.model.SearchProfile; +import nu.marginalia.search.command.CommandEvaluator; +import nu.marginalia.search.command.SearchParameters; import nu.marginalia.search.exceptions.RedirectException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,15 +52,7 @@ public class SearchQueryService { throw new RedirectException(websiteUrl.url()); } - return new SearchParameters(queryParam.trim(), - SearchProfile.getSearchProfile(request.queryParams("profile")), - SearchJsParameter.parse(request.queryParams("js")), - SearchRecentParameter.parse(request.queryParams("recent")), - SearchTitleParameter.parse(request.queryParams("searchTitle")), - SearchAdtechParameter.parse(request.queryParams("adtech")), - "1".equals(request.headers("X-Poison-Results")), - "true".equals(request.queryParams("newfilter")) - ); + return new SearchParameters(queryParam.trim(), request); } catch (Exception ex) { // Bots keep sending bad requests, suppress the error otherwise it will diff --git a/code/services-application/search-service/resources/static/search/serp.scss b/code/services-application/search-service/resources/static/search/serp.scss index 3e25e780..ea4adcd0 100644 --- a/code/services-application/search-service/resources/static/search/serp.scss +++ b/code/services-application/search-service/resources/static/search/serp.scss @@ -794,6 +794,25 @@ footer { } } +.page-link { + padding-top: 0.25ch; + padding-bottom: 0.25ch; + padding-left: 0.5ch; + padding-right: 0.5ch; + margin-right: 0.5ch; + + font-size: 12pt; + border: 1px solid var(--clr-border); + background-color: var(--clr-bg-highlight); + color: var(--clr-text-ui) !important; + text-decoration: none; +} + +.page-link.active { + border: 1px solid var(--clr-text-ui); + background-color: var(--clr-bg-ui); +} + // The search results page is very confusing on text-based browsers, so we add a hr to separate the search results. This is // hidden on modern browsers via CSS. diff --git a/code/services-application/search-service/resources/templates/search/search-results.hdb b/code/services-application/search-service/resources/templates/search/search-results.hdb index 16c3acb9..dfac9019 100644 --- a/code/services-application/search-service/resources/templates/search/search-results.hdb +++ b/code/services-application/search-service/resources/templates/search/search-results.hdb @@ -53,6 +53,12 @@ {{/with}} {{/if}} {{/each}} + + {{#with filters}}