(search) Site info view is mostly done

Also optimize the rendering a bit to avoid having to allocate huge string buffers, writing directly to Spark's response instead.
This commit is contained in:
Viktor Lofgren 2023-11-18 17:42:35 +01:00
parent 2f4500be5a
commit 7c8a60b8cf
36 changed files with 818 additions and 484 deletions

View File

@ -16,6 +16,7 @@ dependencies {
implementation libs.bundles.handlebars implementation libs.bundles.handlebars
implementation libs.guice implementation libs.guice
implementation libs.spark
testImplementation libs.bundles.slf4j.test testImplementation libs.bundles.slf4j.test
testImplementation libs.bundles.junit testImplementation libs.bundles.junit

View File

@ -8,9 +8,12 @@ import lombok.SneakyThrows;
import nu.marginalia.renderer.config.HandlebarsConfigurator; import nu.marginalia.renderer.config.HandlebarsConfigurator;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import spark.Response;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -48,6 +51,23 @@ public class MustacheRenderer<T> {
return template.apply(model); return template.apply(model);
} }
private Writer getWriter(Response response) throws IOException {
// response.raw() has a getWriter() method that fits here, but this is a trap, as subsequent
// calls to response.raw().getOutputStream() will fail with an IllegalStateException; and we
// have internal code that does this.
return new OutputStreamWriter(response.raw().getOutputStream());
}
@SneakyThrows
public Object renderInto(Response response, T model) {
template.apply(model, getWriter(response));
return "";
}
@SneakyThrows @SneakyThrows
public <T2> String render(T model, String name, List<T2> children) { public <T2> String render(T model, String name, List<T2> children) {
Context ctx = Context.newBuilder(model).combine(name, children).build(); Context ctx = Context.newBuilder(model).combine(name, children).build();
@ -55,10 +75,22 @@ public class MustacheRenderer<T> {
return template.apply(ctx); return template.apply(ctx);
} }
@SneakyThrows
public <T2> void renderInto(Response response, T model, String name, List<T2> children) {
Context ctx = Context.newBuilder(model).combine(name, children).build();
template.apply(ctx, getWriter(response));
}
@SneakyThrows @SneakyThrows
public <T2> String render(T model, Map<String, ?> children) { public <T2> String render(T model, Map<String, ?> children) {
Context ctx = Context.newBuilder(model).combine(children).build(); Context ctx = Context.newBuilder(model).combine(children).build();
return template.apply(ctx); return template.apply(ctx);
} }
@SneakyThrows
public <T2> void renderInto(Response response, T model, Map<String, ?> children) {
Context ctx = Context.newBuilder(model).combine(children).build();
template.apply(ctx, getWriter(response));
}
} }

View File

@ -75,7 +75,14 @@ public class SearchOperator {
return searchQueryService.getResultsFromQuery(queryResponse); return searchQueryService.getResultsFromQuery(queryResponse);
} }
public List<UrlDetails> doBacklinkSearch(Context ctx,
String domain) {
var queryParams = paramFactory.forBacklinkSearch(domain);
var queryResponse = queryClient.search(ctx, queryParams);
return searchQueryService.getResultsFromQuery(queryResponse);
}
public DecoratedSearchResults doSearch(Context ctx, SearchParameters userParams) { public DecoratedSearchResults doSearch(Context ctx, SearchParameters userParams) {
Future<String> eval = searchUnitConversionService.tryEval(ctx, userParams.query()); Future<String> eval = searchUnitConversionService.tryEval(ctx, userParams.query());

View File

@ -51,4 +51,22 @@ public class SearchQueryParamFactory {
SearchSetIdentifier.NONE SearchSetIdentifier.NONE
); );
} }
public QueryParams forBacklinkSearch(String domain) {
return new QueryParams("links:"+domain,
null,
List.of(),
List.of(),
List.of(),
List.of(),
SpecificationLimit.none(),
SpecificationLimit.none(),
SpecificationLimit.none(),
SpecificationLimit.none(),
List.of(),
new QueryLimits(100, 100, 100, 512),
SearchSetIdentifier.NONE
);
}
} }

View File

@ -32,6 +32,7 @@ public class SearchService extends Service {
SearchErrorPageService errorPageService, SearchErrorPageService errorPageService,
SearchAddToCrawlQueueService addToCrawlQueueService, SearchAddToCrawlQueueService addToCrawlQueueService,
SearchFlagSiteService flagSiteService, SearchFlagSiteService flagSiteService,
SearchSiteInfoService siteInfoService,
SearchQueryService searchQueryService SearchQueryService searchQueryService
) { ) {
super(params); super(params);
@ -50,10 +51,10 @@ public class SearchService extends Service {
Spark.post("/public/site/suggest/", addToCrawlQueueService::suggestCrawling); Spark.post("/public/site/suggest/", addToCrawlQueueService::suggestCrawling);
Spark.get("/public/site/flag-site/:domainId", flagSiteService::flagSiteForm);
Spark.post("/public/site/flag-site/:domainId", flagSiteService::flagSiteAction);
Spark.get("/public/site-search/:site/*", this::siteSearchRedir); Spark.get("/public/site-search/:site/*", this::siteSearchRedir);
Spark.get("/public/site/:site", this::siteSearchRedir);
Spark.get("/public/site/:site", siteInfoService::handle);
Spark.post("/public/site/:site", siteInfoService::handlePost);
Spark.exception(Exception.class, (e,p,q) -> { Spark.exception(Exception.class, (e,p,q) -> {
logger.error("Error during processing", e); logger.error("Error during processing", e);

View File

@ -3,6 +3,7 @@ package nu.marginalia.search.command;
import com.google.inject.Inject; import com.google.inject.Inject;
import nu.marginalia.search.command.commands.*; import nu.marginalia.search.command.commands.*;
import nu.marginalia.client.Context; import nu.marginalia.client.Context;
import spark.Response;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -17,30 +18,32 @@ public class CommandEvaluator {
BrowseCommand browse, BrowseCommand browse,
ConvertCommand convert, ConvertCommand convert,
DefinitionCommand define, DefinitionCommand define,
SiteListCommand site,
BangCommand bang, BangCommand bang,
SiteRedirectCommand siteRedirect,
SearchCommand search SearchCommand search
) { ) {
specialCommands.add(browse); specialCommands.add(browse);
specialCommands.add(convert); specialCommands.add(convert);
specialCommands.add(define); specialCommands.add(define);
specialCommands.add(site);
specialCommands.add(bang); specialCommands.add(bang);
specialCommands.add(siteRedirect);
defaultCommand = search; defaultCommand = search;
} }
public Object eval(Context ctx, SearchParameters parameters) { public Object eval(Context ctx, Response response, SearchParameters parameters) {
for (var cmd : specialCommands) { for (var cmd : specialCommands) {
var ret = cmd.process(ctx, parameters); if (cmd.process(ctx, response, parameters)) {
if (ret.isPresent()) { // The commands will write directly to the response, so we don't need to do anything else
return ret.get(); // but it's important we don't return null, as this signals to Spark that we haven't handled
// the request.
return "";
} }
} }
// Always process the search command last defaultCommand.process(ctx, response, parameters);
return defaultCommand.process(ctx, parameters) return "";
.orElseThrow(() -> new IllegalStateException("Search Command returned Optional.empty()!") /* This Should Not be Possible™ */ );
} }
} }

View File

@ -2,9 +2,8 @@ package nu.marginalia.search.command;
import nu.marginalia.client.Context; import nu.marginalia.client.Context;
import spark.Response;
import java.util.Optional;
public interface SearchCommandInterface { public interface SearchCommandInterface {
Optional<Object> process(Context ctx, SearchParameters parameters); boolean process(Context ctx, Response response, SearchParameters parameters);
} }

View File

@ -5,6 +5,7 @@ import nu.marginalia.search.command.SearchCommandInterface;
import nu.marginalia.search.command.SearchParameters; import nu.marginalia.search.command.SearchParameters;
import nu.marginalia.client.Context; import nu.marginalia.client.Context;
import nu.marginalia.search.exceptions.RedirectException; import nu.marginalia.search.exceptions.RedirectException;
import spark.Response;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -23,7 +24,7 @@ public class BangCommand implements SearchCommandInterface {
} }
@Override @Override
public Optional<Object> process(Context ctx, SearchParameters parameters) { public boolean process(Context ctx, Response response, SearchParameters parameters) {
for (var entry : bangsToPattern.entrySet()) { for (var entry : bangsToPattern.entrySet()) {
String bangPattern = entry.getKey(); String bangPattern = entry.getKey();
@ -37,7 +38,7 @@ public class BangCommand implements SearchCommandInterface {
} }
} }
return Optional.empty(); return false;
} }
private Optional<String> matchBangPattern(String query, String bangKey) { private Optional<String> matchBangPattern(String query, String bangKey) {

View File

@ -17,6 +17,7 @@ import nu.marginalia.renderer.MustacheRenderer;
import nu.marginalia.renderer.RendererFactory; import nu.marginalia.renderer.RendererFactory;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import spark.Response;
import java.io.IOException; import java.io.IOException;
import java.util.*; import java.util.*;
@ -56,16 +57,22 @@ public class BrowseCommand implements SearchCommandInterface {
} }
@Override @Override
public Optional<Object> process(Context ctx, SearchParameters parameters) { public boolean process(Context ctx, Response response, SearchParameters parameters) {
if (!queryPatternPredicate.test(parameters.query())) { if (!queryPatternPredicate.test(parameters.query())) {
return Optional.empty(); return false;
} }
return Optional.ofNullable(browseSite(ctx, parameters.query())) var model = browseSite(ctx, parameters.query());
.map(results -> browseResultsRenderer.render(results,
if (null == model)
return false;
browseResultsRenderer.renderInto(response, model,
Map.of("query", parameters.query(), Map.of("query", parameters.query(),
"profile", parameters.profileStr(), "profile", parameters.profileStr(),
"focusDomain", results.focusDomain()))); "focusDomain", model.focusDomain())
);
return true;
} }

View File

@ -1,16 +1,17 @@
package nu.marginalia.search.command.commands; package nu.marginalia.search.command.commands;
import com.google.inject.Inject; import com.google.inject.Inject;
import lombok.SneakyThrows;
import nu.marginalia.search.command.SearchCommandInterface; import nu.marginalia.search.command.SearchCommandInterface;
import nu.marginalia.search.command.SearchParameters; import nu.marginalia.search.command.SearchParameters;
import nu.marginalia.search.svc.SearchUnitConversionService; import nu.marginalia.search.svc.SearchUnitConversionService;
import nu.marginalia.client.Context; import nu.marginalia.client.Context;
import nu.marginalia.renderer.MustacheRenderer; import nu.marginalia.renderer.MustacheRenderer;
import nu.marginalia.renderer.RendererFactory; import nu.marginalia.renderer.RendererFactory;
import spark.Response;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
import java.util.Optional;
public class ConvertCommand implements SearchCommandInterface { public class ConvertCommand implements SearchCommandInterface {
private final SearchUnitConversionService searchUnitConversionService; private final SearchUnitConversionService searchUnitConversionService;
@ -24,16 +25,19 @@ public class ConvertCommand implements SearchCommandInterface {
} }
@Override @Override
public Optional<Object> process(Context ctx, SearchParameters parameters) { @SneakyThrows
public boolean process(Context ctx, Response response, SearchParameters parameters) {
var conversion = searchUnitConversionService.tryConversion(ctx, parameters.query()); var conversion = searchUnitConversionService.tryConversion(ctx, parameters.query());
if (conversion.isEmpty()) { if (conversion.isEmpty()) {
return Optional.empty(); return false;
} }
return Optional.of(conversionRenderer.render(Map.of( conversionRenderer.renderInto(response, Map.of(
"query", parameters.query(), "query", parameters.query(),
"result", conversion.get(), "result", conversion.get(),
"profile", parameters.profileStr())) "profile", parameters.profileStr())
); );
return true;
} }
} }

View File

@ -12,10 +12,10 @@ import nu.marginalia.renderer.MustacheRenderer;
import nu.marginalia.renderer.RendererFactory; import nu.marginalia.renderer.RendererFactory;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import spark.Response;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.function.Predicate; import java.util.function.Predicate;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -38,17 +38,19 @@ public class DefinitionCommand implements SearchCommandInterface {
} }
@Override @Override
public Optional<Object> process(Context ctx, SearchParameters parameters) { public boolean process(Context ctx, Response response, SearchParameters parameters) {
if (!queryPatternPredicate.test(parameters.query())) { if (!queryPatternPredicate.test(parameters.query())) {
return Optional.empty(); return false;
} }
var results = lookupDefinition(ctx, parameters.query()); var results = lookupDefinition(ctx, parameters.query());
return Optional.of(dictionaryRenderer.render(results, dictionaryRenderer.renderInto(response, results,
Map.of("query", parameters.query(), Map.of("query", parameters.query(),
"profile", parameters.profileStr()) "profile", parameters.profileStr())
)); );
return true;
} }

View File

@ -10,9 +10,9 @@ import nu.marginalia.search.model.DecoratedSearchResults;
import nu.marginalia.search.model.UrlDetails; import nu.marginalia.search.model.UrlDetails;
import nu.marginalia.renderer.MustacheRenderer; import nu.marginalia.renderer.MustacheRenderer;
import nu.marginalia.renderer.RendererFactory; import nu.marginalia.renderer.RendererFactory;
import spark.Response;
import java.io.IOException; import java.io.IOException;
import java.util.Optional;
public class SearchCommand implements SearchCommandInterface { public class SearchCommand implements SearchCommandInterface {
private final DomainBlacklist blacklist; private final DomainBlacklist blacklist;
@ -32,10 +32,12 @@ public class SearchCommand implements SearchCommandInterface {
} }
@Override @Override
public Optional<Object> process(Context ctx, SearchParameters parameters) { public boolean process(Context ctx, Response response, SearchParameters parameters) {
DecoratedSearchResults results = searchOperator.doSearch(ctx, parameters); DecoratedSearchResults results = searchOperator.doSearch(ctx, parameters);
return Optional.of(searchResultsRenderer.render(results)); searchResultsRenderer.renderInto(response, results);
return true;
} }
private boolean isBlacklisted(UrlDetails details) { private boolean isBlacklisted(UrlDetails details) {

View File

@ -1,119 +0,0 @@
package nu.marginalia.search.command.commands;
import com.google.inject.Inject;
import nu.marginalia.db.DbDomainQueries;
import nu.marginalia.model.EdgeDomain;
import nu.marginalia.search.SearchOperator;
import nu.marginalia.search.model.UrlDetails;
import nu.marginalia.search.command.SearchCommandInterface;
import nu.marginalia.search.command.SearchParameters;
import nu.marginalia.search.model.DomainInformation;
import nu.marginalia.search.model.SearchProfile;
import nu.marginalia.search.siteinfo.DomainInformationService;
import nu.marginalia.search.svc.SearchQueryIndexService;
import nu.marginalia.client.Context;
import nu.marginalia.renderer.MustacheRenderer;
import nu.marginalia.renderer.RendererFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.function.Predicate;
import java.util.regex.Pattern;
public class SiteListCommand implements SearchCommandInterface {
private final DbDomainQueries domainQueries;
private final DomainInformationService domainInformationService;
private final SearchQueryIndexService searchQueryIndexService;
private final SearchOperator searchOperator;
private final Logger logger = LoggerFactory.getLogger(getClass());
private final MustacheRenderer<DomainInformation> siteInfoRenderer;
private final Predicate<String> queryPatternPredicate = Pattern.compile("^site:[.A-Za-z\\-0-9]+$").asPredicate();
@Inject
public SiteListCommand(
DomainInformationService domainInformationService,
DbDomainQueries domainQueries,
RendererFactory rendererFactory,
SearchQueryIndexService searchQueryIndexService, SearchOperator searchOperator)
throws IOException
{
this.domainQueries = domainQueries;
this.domainInformationService = domainInformationService;
siteInfoRenderer = rendererFactory.renderer("search/site-info");
this.searchQueryIndexService = searchQueryIndexService;
this.searchOperator = searchOperator;
}
@Override
public Optional<Object> process(Context ctx, SearchParameters parameters) {
if (!queryPatternPredicate.test(parameters.query())) {
return Optional.empty();
}
var results = siteInfo(ctx, parameters.query());
var domain = results.getDomain();
List<UrlDetails> resultSet;
Path screenshotPath = null;
int domainId = -1;
if (null != domain) {
resultSet = searchOperator.doSiteSearch(ctx, domain.toString());
var maybeId = domainQueries.tryGetDomainId(domain);
if (maybeId.isPresent()) {
domainId = maybeId.getAsInt();
screenshotPath = Path.of("/screenshot/" + domainId);
}
else {
domainId = -1;
screenshotPath = Path.of("/screenshot/0");
}
}
else {
resultSet = Collections.emptyList();
}
Map<String, Object> renderObject = new HashMap<>(10);
renderObject.put("query", parameters.query());
renderObject.put("hideRanking", true);
renderObject.put("profile", parameters.profileStr());
renderObject.put("results", resultSet);
renderObject.put("screenshot", screenshotPath == null ? "" : screenshotPath.toString());
renderObject.put("domainId", domainId);
renderObject.put("focusDomain", domain);
return Optional.of(siteInfoRenderer.render(results, renderObject));
}
private DomainInformation siteInfo(Context ctx, String humanQuery) {
String definePrefix = "site:";
String word = humanQuery.substring(definePrefix.length()).toLowerCase();
logger.info("Fetching Site Info: {}", word);
var results = domainInformationService
.domainInfo(word)
.orElseGet(() -> unknownSite(word));
logger.debug("Results = {}", results);
return results;
}
private DomainInformation unknownSite(String url) {
return DomainInformation.builder()
.domain(new EdgeDomain(url))
.suggestForCrawling(true)
.unknownDomain(true)
.build();
}
}

View File

@ -0,0 +1,49 @@
package nu.marginalia.search.command.commands;
import com.google.inject.Inject;
import lombok.SneakyThrows;
import nu.marginalia.client.Context;
import nu.marginalia.search.command.SearchCommandInterface;
import nu.marginalia.search.command.SearchParameters;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import spark.Response;
import java.io.IOException;
import java.util.function.Predicate;
import java.util.regex.Pattern;
public class SiteRedirectCommand implements SearchCommandInterface {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final Predicate<String> queryPatternPredicate = Pattern.compile("^site:[.A-Za-z\\-0-9]+$").asPredicate();
@Inject
public SiteRedirectCommand() {
}
@SneakyThrows
@Override
public boolean process(Context ctx, Response response, SearchParameters parameters) {
if (!queryPatternPredicate.test(parameters.query())) {
return false;
}
String definePrefix = "site:";
String domain = parameters.query().substring(definePrefix.length()).toLowerCase();
// Use an HTML redirect here, so we can use relative URLs
response.raw().getOutputStream().println("""
<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<title>Redirecting...</title>
<meta http-equiv="refresh" content="0; url=/site/%s">
""".formatted(domain));
return true;
}
}

View File

@ -76,7 +76,7 @@ public class DomainInformationService {
.linkingDomains(linkingDomains) .linkingDomains(linkingDomains)
.inCrawlQueue(inCrawlQueue) .inCrawlQueue(inCrawlQueue)
.nodeAffinity(nodeAffinity) .nodeAffinity(nodeAffinity)
.suggestForCrawling((pagesVisited == 0 && !inCrawlQueue)) .suggestForCrawling((pagesVisited == 0 && outboundLinks == 0 && !inCrawlQueue))
.build(); .build();
return Optional.of(di); return Optional.of(di);

View File

@ -2,13 +2,7 @@ package nu.marginalia.search.svc;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.HikariDataSource;
import nu.marginalia.renderer.MustacheRenderer;
import nu.marginalia.renderer.RendererFactory;
import spark.Request;
import spark.Response;
import spark.Spark;
import java.io.IOException;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
@ -21,7 +15,6 @@ import java.util.stream.Collectors;
* DomainComplaintService in control-service * DomainComplaintService in control-service
*/ */
public class SearchFlagSiteService { public class SearchFlagSiteService {
private final MustacheRenderer<FlagSiteViewModel> formTemplate;
private final HikariDataSource dataSource; private final HikariDataSource dataSource;
private final CategoryItem unknownCategory = new CategoryItem("unknown", "Unknown"); private final CategoryItem unknownCategory = new CategoryItem("unknown", "Unknown");
@ -39,61 +32,20 @@ public class SearchFlagSiteService {
private final Map<String, CategoryItem> categoryItemMap = private final Map<String, CategoryItem> categoryItemMap =
categories.stream().collect(Collectors.toMap(CategoryItem::categoryName, Function.identity())); categories.stream().collect(Collectors.toMap(CategoryItem::categoryName, Function.identity()));
@Inject @Inject
public SearchFlagSiteService(RendererFactory rendererFactory, public SearchFlagSiteService(HikariDataSource dataSource) {
HikariDataSource dataSource) throws IOException {
formTemplate = rendererFactory.renderer("search/indict/indict-form");
this.dataSource = dataSource; this.dataSource = dataSource;
} }
public Object flagSiteForm(Request request, Response response) throws SQLException { public List<CategoryItem> getCategories() {
final int domainId = Integer.parseInt(request.params("domainId")); return categories;
var model = getModel(domainId, false);
return formTemplate.render(model);
} }
public Object flagSiteAction(Request request, Response response) throws SQLException { public List<FlagSiteComplaintModel> getExistingComplaints(int id) throws SQLException {
int domainId = Integer.parseInt(request.params("domainId"));
var formData = new FlagSiteFormData(
domainId,
request.queryParams("category"),
request.queryParams("description"),
request.queryParams("samplequery")
);
insertComplaint(formData);
return formTemplate.render(getModel(domainId, true));
}
private void insertComplaint(FlagSiteFormData formData) throws SQLException {
try (var conn = dataSource.getConnection();
var stmt = conn.prepareStatement(
"""
INSERT INTO DOMAIN_COMPLAINT(DOMAIN_ID, CATEGORY, DESCRIPTION, SAMPLE) VALUES (?, ?, ?, ?)
""")) {
stmt.setInt(1, formData.domainId);
stmt.setString(2, formData.category);
stmt.setString(3, formData.description);
stmt.setString(4, formData.sampleQuery);
stmt.executeUpdate();
}
}
private FlagSiteViewModel getModel(int id, boolean isSubmitted) throws SQLException {
try (var conn = dataSource.getConnection(); try (var conn = dataSource.getConnection();
var complaintsStmt = conn.prepareStatement(""" var complaintsStmt = conn.prepareStatement("""
SELECT CATEGORY, FILE_DATE, REVIEWED, DECISION SELECT CATEGORY, FILE_DATE, REVIEWED, DECISION
FROM DOMAIN_COMPLAINT FROM DOMAIN_COMPLAINT
WHERE DOMAIN_ID=? WHERE DOMAIN_ID=?
""");
var stmt = conn.prepareStatement(
"""
SELECT DOMAIN_NAME FROM EC_DOMAIN WHERE EC_DOMAIN.ID=?
""")) """))
{ {
List<FlagSiteComplaintModel> complaints = new ArrayList<>(); List<FlagSiteComplaintModel> complaints = new ArrayList<>();
@ -109,21 +61,25 @@ public class SearchFlagSiteService {
rs.getString(4))); rs.getString(4)));
} }
stmt.setInt(1, id); return complaints;
rs = stmt.executeQuery();
if (!rs.next()) {
Spark.halt(404);
} }
return new FlagSiteViewModel(id, }
rs.getString(1),
categories, public void insertComplaint(FlagSiteFormData formData) throws SQLException {
complaints, try (var conn = dataSource.getConnection();
isSubmitted); var stmt = conn.prepareStatement(
"""
INSERT INTO DOMAIN_COMPLAINT(DOMAIN_ID, CATEGORY, DESCRIPTION, SAMPLE) VALUES (?, ?, ?, ?)
""")) {
stmt.setInt(1, formData.domainId);
stmt.setString(2, formData.category);
stmt.setString(3, formData.description);
stmt.setString(4, formData.sampleQuery);
stmt.executeUpdate();
} }
} }
public record CategoryItem(String categoryName, String categoryDesc) {} public record CategoryItem(String categoryName, String categoryDesc) {}
public record FlagSiteViewModel(int domainId, String domain, List<CategoryItem> category, List<FlagSiteComplaintModel> complaints, boolean isSubmitted) {}
public record FlagSiteComplaintModel(String category, String submitTime, boolean isReviewed, String decision) {} public record FlagSiteComplaintModel(String category, String submitTime, boolean isReviewed, String decision) {}
public record FlagSiteFormData(int domainId, String category, String description, String sampleQuery) {}; public record FlagSiteFormData(int domainId, String category, String description, String sampleQuery) {};
} }

View File

@ -37,7 +37,7 @@ public class SearchQueryService {
final var ctx = Context.fromRequest(request); final var ctx = Context.fromRequest(request);
try { try {
return searchCommandEvaulator.eval(ctx, parseParameters(request)); return searchCommandEvaulator.eval(ctx, response, parseParameters(request));
} }
catch (RedirectException ex) { catch (RedirectException ex) {
response.redirect(ex.newUrl); response.redirect(ex.newUrl);

View File

@ -0,0 +1,236 @@
package nu.marginalia.search.svc;
import com.google.inject.Inject;
import nu.marginalia.client.Context;
import nu.marginalia.db.DbDomainQueries;
import nu.marginalia.model.EdgeDomain;
import nu.marginalia.renderer.MustacheRenderer;
import nu.marginalia.renderer.RendererFactory;
import nu.marginalia.search.SearchOperator;
import nu.marginalia.search.model.DomainInformation;
import nu.marginalia.search.model.UrlDetails;
import nu.marginalia.search.siteinfo.DomainInformationService;
import nu.marginalia.search.svc.SearchFlagSiteService.FlagSiteFormData;
import spark.*;
import javax.annotation.Nullable;
import java.io.IOException;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.OptionalInt;
public class SearchSiteInfoService {
private final SearchOperator searchOperator;
private final DomainInformationService domainInformationService;
private final SearchFlagSiteService flagSiteService;
private final DbDomainQueries domainQueries;
private final MustacheRenderer<Object> renderer;
@Inject
public SearchSiteInfoService(SearchOperator searchOperator,
DomainInformationService domainInformationService,
RendererFactory rendererFactory,
SearchFlagSiteService flagSiteService,
DbDomainQueries domainQueries) throws IOException {
this.searchOperator = searchOperator;
this.domainInformationService = domainInformationService;
this.flagSiteService = flagSiteService;
this.domainQueries = domainQueries;
this.renderer = rendererFactory.renderer("search/site-info/site-info");
}
public Object handle(Request request, Response response) throws SQLException {
String domainName = request.params("site");
String view = request.queryParamOrDefault("view", "info");
if (null == domainName || domainName.isBlank()) {
return null;
}
var ctx = Context.fromRequest(request);
var model = switch (view) {
case "links" -> listLinks(ctx, domainName);
case "docs" -> listDocs(ctx, domainName);
case "info" -> siteInfo(ctx, domainName);
case "report" -> reportSite(ctx, domainName);
default -> siteInfo(ctx, domainName);
};
return renderer.renderInto(response, model);
}
public Object handlePost(Request request, Response response) throws SQLException {
String domainName = request.params("site");
String view = request.queryParamOrDefault("view", "info");
if (null == domainName || domainName.isBlank()) {
return null;
}
if (!view.equals("report"))
return null;
final int domainId = domainQueries.getDomainId(new EdgeDomain(domainName));
FlagSiteFormData formData = new FlagSiteFormData(
domainId,
request.queryParams("category"),
request.queryParams("description"),
request.queryParams("sampleQuery")
);
flagSiteService.insertComplaint(formData);
var complaints = flagSiteService.getExistingComplaints(domainId);
var model = new ReportDomain(domainName, domainId, complaints, List.of(), true);
return renderer.renderInto(response, model);
}
private Object reportSite(Context ctx, String domainName) throws SQLException {
int domainId = domainQueries.getDomainId(new EdgeDomain(domainName));
var existingComplaints = flagSiteService.getExistingComplaints(domainId);
return new ReportDomain(domainName,
domainId,
existingComplaints,
flagSiteService.getCategories(),
false);
}
private SiteInfo siteInfo(Context ctx, String domainName) {
OptionalInt id = domainQueries.tryGetDomainId(new EdgeDomain(domainName));
if (id.isEmpty()) {
return new SiteInfo(domainName, -1, null, dummyInformation(domainName));
}
String screenshotPath = "/screenshot/"+id.getAsInt();
DomainInformation domainInfo = domainInformationService
.domainInfo(domainName)
.orElseGet(() -> dummyInformation(domainName));
return new SiteInfo(domainName, id.getAsInt(), screenshotPath, domainInfo);
}
private DomainInformation dummyInformation(String domainName) {
return DomainInformation.builder()
.domain(new EdgeDomain(domainName))
.suggestForCrawling(true)
.unknownDomain(true)
.build();
}
private Backlinks listLinks(Context ctx, String domainName) {
return new Backlinks(domainName,
domainQueries.tryGetDomainId(new EdgeDomain(domainName)).orElse(-1),
searchOperator.doBacklinkSearch(ctx, domainName));
}
private Docs listDocs(Context ctx, String domainName) {
return new Docs(domainName,
domainQueries.tryGetDomainId(new EdgeDomain(domainName)).orElse(-1),
searchOperator.doSiteSearch(ctx, domainName));
}
public record SiteInfo(Map<String, Boolean> view,
Map<String, Boolean> domainState,
long domainId,
String domain,
@Nullable String screenshotUrl,
DomainInformation domainInformation)
{
public SiteInfo(String domain,
long domainId,
@Nullable String screenshotUrl,
DomainInformation domainInformation)
{
this(Map.of("info", true),
Map.of(domainInfoState(domainInformation), true),
domainId,
domain,
screenshotUrl,
domainInformation);
}
private static String domainInfoState(DomainInformation info) {
if (info.isBlacklisted()) {
return "blacklisted";
}
if (!info.isUnknownDomain() && info.isSuggestForCrawling()) {
return "suggestForCrawling";
}
if (info.isInCrawlQueue()) {
return "inCrawlQueue";
}
if (info.isUnknownDomain()) {
return "unknownDomain";
}
else {
return "indexed";
}
}
public String query() { return "site:" + domain; }
public boolean isKnown() {
return domainId > 0;
}
}
public record Docs(Map<String, Boolean> view,
String domain,
long domainId,
List<UrlDetails> results) {
public Docs(String domain, long domainId, List<UrlDetails> results) {
this(Map.of("docs", true), domain, domainId, results);
}
public String focusDomain() { return domain; }
public String query() { return "site:" + domain; }
public boolean isKnown() {
return domainId > 0;
}
}
public record Backlinks(Map<String, Boolean> view, String domain, long domainId, List<UrlDetails> results) {
public Backlinks(String domain, long domainId, List<UrlDetails> results) {
this(Map.of("links", true), domain, domainId, results);
}
public String query() { return "links:" + domain; }
public boolean isKnown() {
return domainId > 0;
}
}
public record ReportDomain(
Map<String, Boolean> view,
String domain,
int domainId,
List<SearchFlagSiteService.FlagSiteComplaintModel> complaints,
List<SearchFlagSiteService.CategoryItem> category,
boolean submitted)
{
public ReportDomain(String domain,
int domainId,
List<SearchFlagSiteService.FlagSiteComplaintModel> complaints,
List<SearchFlagSiteService.CategoryItem> category,
boolean submitted) {
this(Map.of("report", true), domain, domainId, complaints, category, submitted);
}
public String query() { return "site:" + domain; }
public boolean isKnown() {
return domainId > 0;
}
}
}

View File

@ -25,10 +25,68 @@ body {
padding: 0; padding: 0;
} }
#siteinfo-nav {
display: block;
width: 100%;
@extend .dialog;
padding: 0.25ch !important;
margin-top: 1.5ch;
ul {
list-style: none;
padding: 0;
margin: 1ch;
li {
display: inline;
padding: 1ch;
background-color: $highlight-light2;
a {
text-decoration: none;
display: inline-block;
color: #000;
}
.link-unavailable {
display: inline-block;
text-decoration: line-through;
color: #888;
}
}
li.current {
background-color: $highlight-light;
a {
color: #fff;
}
}
}
}
.dialog {
border: 1px solid $border-color;
box-shadow: 0 0 1ch $border-color;
background-color: #fff;
padding: 1ch;
h2 {
margin: 0;
font-family: sans-serif;
font-weight: normal;
padding: 0.5ch;
font-size: 12pt;
background-color: $highlight-light;
color: #fff;
}
}
header { header {
background-color: $nicotine-dark; background-color: $nicotine-dark;
color: #fff; color: #fff;
border-bottom: 1px solid $border-color; border: 1px solid #888;
box-shadow: 0 0 0.5ch #888;
margin-bottom: 1ch; margin-bottom: 1ch;
nav { nav {
@ -46,6 +104,7 @@ header {
rgba(100,255,100,1) 50%, rgba(100,255,100,1) 50%,
rgba(100,100,255,1) 100%); rgba(100,100,255,1) 100%);
color: black; color: black;
text-shadow: 0 0 0.25ch #ccc;
} }
a:hover, a:focus { a:hover, a:focus {
@ -55,6 +114,119 @@ header {
} }
} }
#complaint {
@extend .dialog;
max-width: 60ch;
margin-left: auto;
margin-right: auto;
margin-top: 2ch;
textarea {
width: 100%;
height: 10ch;
}
}
#siteinfo {
margin-top: 1ch;
display: flex;
gap: 1ch;
flex-grow: 0.5;
flex-shrink: 0.5;
flex-basis: 10ch 10ch;
flex-direction: row;
flex-wrap: wrap;
align-content: stretch;
align-items: stretch;
justify-content: stretch;
#index-info, #link-info {
width: 32ch;
@extend .dialog;
}
#screenshot {
@extend .dialog;
}
#screenshot img {
width: 30ch;
height: 22.5ch;
}
}
.infobox {
background-color: #fff;
padding: 1ch;
margin: 1ch;
border: 1px solid $border-color;
box-shadow: 0 0 1ch $border-color;
}
section.cards {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding-top: 1ch;
gap: 2ch;
justify-content: flex-start;
.card {
border: 2px #ccc;
background-color: #fff;
border-left: 1px solid #ecb;
border-top: 1px solid #ecb;
box-shadow: #0008 0 0 5px;
h2 {
color: #fff;
background-color: $highlight-light;
border-bottom: 1px solid $border-color;
font-weight: normal;
font-size: 12pt;
padding: .5ch .5ch .5ch .5ch;
margin: 0 0 0 0;
word-break: break-word;
font-family: $heading-fonts;
text-decoration: none;
}
h2 a {
display: block !important;
color: #fff;
text-decoration: none;
}
a:focus img {
filter: sepia(100%);
box-shadow: #444 0px 0px 20px;
}
a:focus:not(.nofocus) {
background-color: black;
color: white;
}
.description {
padding-left: 1ch;
padding-right: 1ch;
overflow: auto;
-webkit-hyphens: auto;
-moz-hyphens: auto;
-ms-hyphens: auto;
hyphens: auto;
}
img {
width: 28ch;
height: auto;
}
.info {
padding-left: 1ch;
padding-right: 1ch;
line-height: 1.6;
}
}
}
.positions { .positions {
box-shadow: 0 0 2px #888; box-shadow: 0 0 2px #888;
background-color: #e4e4e4; background-color: #e4e4e4;
@ -258,6 +430,8 @@ footer {
margin: 0; margin: 0;
} }
}
.utils { .utils {
display: flex; display: flex;
font-size: 10pt; font-size: 10pt;
@ -278,10 +452,7 @@ footer {
a { a {
color: #000; color: #000;
} }
} }
}
@media (max-device-width: 624px) { @media (max-device-width: 624px) {
body[data-has-js="true"] { body[data-has-js="true"] {
margin: 0 !important; margin: 0 !important;

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Marginalia Search - {{query}}</title> <title>Marginalia Search - {{query}}</title>
<link rel="stylesheet" href="/style-new.css" /> <link rel="stylesheet" href="/serp.css" />
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Marginalia"> <link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Marginalia">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />

View File

@ -1,80 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Marginalia Search - File complaint against {{domain}}</title>
<link rel="stylesheet" href="/style-new.css" />
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Marginalia">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
</head>
<body>
{{>search/parts/search-header}}
<article>
{{>search/parts/search-form}}
<section class="form">
{{#if isSubmitted}}
<h1>Your complaint against {{domain}} has been submitted</h1>
<p>The review process is manual and may take a while.</p>
{{/if}}
{{#unless isSubmitted}}
<h1>Flag {{domain}} for review</h1>
Note, this is not intended to police acceptable thoughts or ideas.
<p>
That said, offensive content in obvious bad faith is not tolerated, especially when designed
to crop up when you didn't go looking for it. How and where it is said is more
important than what is said.
<p>
This form can also be used to appeal unfairly blacklisted sites.
<p>
<form method="POST" action="/site/flag-site/{{domainId}}">
<fieldset>
<legend>Flag for Review</legend>
<label for="category">Category</label><br>
<select name="category" id="category">
{{#each category}} <option value="{{categoryName}}">{{categoryDesc}}</option> {{/each}}
</select>
<br>
<br>
<label for="description">Description</label><br>
<textarea type="text" name="description" id="description" rows=4></textarea><br>
<br>
<label for="samplequery">(Optional) Search Query </label><br>
<input type="text" name="samplequery" id="samplequery" length=255 /><br>
<br>
<br/>
<input type="submit" value="File complaint" />
</fieldset>
</form>
<p>
Communicating through forms and tables is a bit impersonal,
you may also reach a human being through email at <tt>kontakt@marginalia.nu</tt>.
{{/unless}}
{{#if complaints}}
<hr>
<h2> Complaints against {{domain}} </h2>
<table border width=100%>
<tr><th>Category</th><th>Submitted</th><th>Reviewed</th></tr>
{{#each complaints}}
<tr>
<td>{{category}}</td>
<td>{{submitTime}}</td>
<td>{{#if reviewed}}&check;{{/if}}</td>
</tr>
{{/each}}
</table>
{{/if}}
</section>
{{>search/parts/search-footer}}
</body>

View File

@ -1,4 +1,4 @@
<form action="search" method="get"> <form action="/search" method="get">
<div id="search-box"> <div id="search-box">
<h1> <h1>
Search The Internet Search The Internet

View File

@ -1,66 +0,0 @@
<h2>Indexing Information</h2>
<div class="description">
<br>
{{#if blacklisted}}
This website is <em>blacklisted</em>. This excludes it from crawling and indexing.
<p>This is usually because of some form of misbehavior on the webmaster's end.
Either annoying search engine spam, or tasteless content bad faith content.
<p>Occasionally this is done hastily and in error. If you would like the decision
reviewed, you may use <a href="/site/flag-site/{{domainId}}">this form</a> to file a report.</tt>
{{/if}}
{{#unless blacklisted}}
<fieldset>
<legend>Index</legend>
State: {{state}}<br/>
Node Affinity: {{nodeAffinity}} </br>
Pages Known: {{pagesKnown}} <br/>
Pages Crawled: {{pagesFetched}} <br/>
Pages Indexed: {{pagesIndexed}} <br/>
</fieldset>
<br/>
{{#if inCrawlQueue}}
This website is in the queue for crawling.
It may take up to a month before it is indexed.
{{/if}}
{{#if suggestForCrawling}}
{{#if unknownDomain}}
<fieldset>
<legend>Crawling</legend>
This website is not known to the search engine.
To submit the website for crawling, follow <a
rel="noopener noreferrer"
target="_blank"
href="https://github.com/MarginaliaSearch/submit-site-to-marginalia-search">these instructions</a>.
</fieldset>
{{/if}}
{{#unless unknownDomain}}
<form method="POST" action="/site/suggest/">
<fieldset>
<legend>Crawling</legend>
This website is not queued for crawling. If you would like it to be crawled,
use the checkbox and button below.<p/>
<input type="hidden" name="id" value="{{domainId}}" />
<input type="checkbox" id="nomisclick" name="nomisclick" /> <label for="nomisclick"> This is not a mis-click </label>
<br/>
<br/>
<input type="submit" value="Add {{domain}} to queue" />
</fieldset>
</form>
{{/unless}}
{{/if}}
{{#if pagesFetched}}
<p>
If you've found a reason why this website should not be indexed,
you may use <a href="/site/flag-site/{{domainId}}">this form</a> to file a report.<p>
{{/if}}
{{/unless}}
</div>
</p>
</div>

View File

@ -1,18 +0,0 @@
<div class="card info">
<h2>Links</h2>
<div class="description">
<br>
<fieldset>
<legend>Link Graph</legend>
Ranking: {{ranking}}%<br/>
Incoming Links: {{incomingLinks}} <br/>
Outbound Links: {{outboundLinks}} <br/>
</fieldset>
<br>
<fieldset>
<legend>Explore</legend>
<a href="/links/{{domain.domain}}">Which pages link here?</a><br/>
<a href="/explore/{{domain}}">Explore similar domains</a><br/>
</fieldset>
</div>
</div>

View File

@ -20,6 +20,12 @@
<section class="sidebar-narrow"> <section class="sidebar-narrow">
<section id="results" class="sb-left"> <section id="results" class="sb-left">
{{#if focusDomain}}
<div class="infobox">
Showing search results from <a href="/site/{{focusDomain}}">{{focusDomain}}</a>.
</div>
{{/if}}
{{#each results}}{{>search/parts/search-result}}{{/each}} {{#each results}}{{>search/parts/search-result}}{{/each}}
</section> </section>

View File

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Marginalia Search - {{query}}</title>
<link rel="stylesheet" href="/style-new.css" />
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Marginalia">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
</head>
<body>
{{>search/parts/search-header}}
<article>
{{>search/parts/search-form}}
<section class="cards">
<div class="card">
<h2>{{domain}}</h2>
<a href="http://{{domain}}/"><img src="{{screenshot}}" alt="Thumbnail image of {{domain}}"/></a>
</div>
<div class="card info">
{{>search/parts/site-info-index}}
{{>search/parts/site-info-links}}
{{#each results}}{{>search/parts/search-result}}{{/each}}
</section>
</article>
{{>search/parts/search-footer}}
</body>

View File

@ -0,0 +1,8 @@
<p>This website is <em>blacklisted</em>. This excludes it from crawling and indexing.</p>
<p>This is usually because of some form of misbehavior on the webmaster's end.
Either annoying search engine spam, or tasteless content bad faith content.</p>
<p>Occasionally this is done hastily and in error. If you would like the decision
reviewed, you may use the <a href="?v=report">report form</a> to file an appeal.</tt>
</p>

View File

@ -0,0 +1,16 @@
<fieldset>
<legend>Index</legend>
State: {{state}}<br/>
Domain ID: {{domainId}} <br/>
Node Affinity: {{nodeAffinity}} <br/>
Pages Known: {{pagesKnown}} <br/>
Pages Crawled: {{pagesFetched}} <br/>
Pages Indexed: {{pagesIndexed}} <br/>
</fieldset>
<br/>
{{#if pagesFetched}}
<p>
If you've found a reason why this website should not be indexed,
you may use <a href="/site/flag-site/{{domainId}}">this form</a> to file a report.<p>
{{/if}}

View File

@ -0,0 +1,12 @@
<form method="POST" action="/site/suggest/">
<fieldset>
<legend>Crawling</legend>
This website is not queued for crawling. If you would like it to be crawled,
use the checkbox and button below.<p/>
<input type="hidden" name="id" value="{{domainId}}" />
<input type="checkbox" id="nomisclick" name="nomisclick" /> <label for="nomisclick"> This is not a mis-click </label>
<br/>
<br/>
<input type="submit" value="Add {{domain}} to queue" />
</fieldset>
</form>

View File

@ -0,0 +1,9 @@
<fieldset>
<legend>Crawling</legend>
This website is not known to the search engine.
To submit the website for crawling, follow <a
rel="noopener noreferrer"
target="_blank"
href="https://github.com/MarginaliaSearch/submit-site-to-marginalia-search">these instructions</a>.
</fieldset>

View File

@ -0,0 +1,25 @@
<section id="index-info">
<h2>Indexing Information</h2>
{{#if domainState.blacklisted}}
{{>search/site-info/site-info-index-blacklisted}}
{{/if}}
{{#if domainState.unknownDomain}}
{{>search/site-info/site-info-index-unknown}}
{{/if}}
{{#if domainState.inCrawlQueue}}
<p>
This website is in the queue for crawling.
It may take up to a month before it is indexed.
</p>
{{/if}}
{{#if domainState.suggestForCrawling}}
{{>search/site-info/site-info-index-suggest}}
{{/if}}
{{#if domainState.indexed}}
{{>search/site-info/site-info-index-indexed}}
{{/if}}
</section>

View File

@ -0,0 +1,9 @@
<section id="link-info">
<h2>Links</h2>
<fieldset>
<legend>Link Graph</legend>
Ranking: {{ranking}}%<br/>
Incoming Links: {{incomingLinks}} <br/>
Outbound Links: {{outboundLinks}} <br/>
</fieldset>
</section>

View File

@ -0,0 +1,60 @@
<section id="complaint">
{{#if submitted}}
<h2>Your complaint against {{domain}} has been submitted</h2>
<p>The review process is manual and may take a while. If urgent action is necessary,
reach me at kontakt@marginalia.nu!
</p>
{{/if}}
{{#unless submitted}}
<h2>Flag {{domain}} for review</h2>
<p>
Note, this is not intended to police acceptable thoughts or ideas.
<p>
That said, offensive content in obvious bad faith is not tolerated, especially when designed
to crop up when you didn't go looking for it. How and where it is said is more
important than what is said.
<p>
This form can also be used to appeal unfairly blacklisted sites.
<p>
<form method="POST">
<fieldset>
<legend>Flag for Review</legend>
<label for="category">Category</label><br>
<select name="category" id="category">
{{#each category}} <option value="{{categoryName}}">{{categoryDesc}}</option> {{/each}}
</select>
<br>
<br>
<label for="description">Description</label><br>
<textarea type="text" name="description" id="description" rows=4></textarea><br>
<br>
<label for="samplequery">(Optional) Search Query </label><br>
<input type="text" name="samplequery" id="samplequery" length=255 /><br>
<br>
<br/>
<input type="submit" value="File complaint" />
</fieldset>
</form>
<p>
Communicating through forms and tables is a bit impersonal,
you may also reach a human being through email at <tt>kontakt@marginalia.nu</tt>.
{{/unless}}
{{#if complaints}}
<hr>
<h2> Complaints against {{domain}} </h2>
<table border width=100%>
<tr><th>Category</th><th>Submitted</th><th>Reviewed</th></tr>
{{#each complaints}}
<tr>
<td>{{category}}</td>
<td>{{submitTime}}</td>
<td>{{#if reviewed}}&check;{{/if}}</td>
</tr>
{{/each}}
</table>
{{/if}}
</section>

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Marginalia Search - {{domain}}</title>
<link rel="stylesheet" href="/serp.css" />
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Marginalia">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
</head>
<body>
{{>search/parts/search-header}}
{{>search/parts/search-form}}
{{#with view}}
<nav id="siteinfo-nav">
<h2>{{domain}}</h2>
<ul>
<li {{#if info}}class="current"{{/if}}><a href="?view=info">Info</a></li>
<li {{#if report}}class="current"{{/if}}>{{#if known}}<a href="?view=report">Report</a>{{/if}}{{#unless known}}<a class="link-unavailable" title="This domain is not known by the search engine">Report</a>{{/unless}}</li>
<li {{#if docs}}class="current"{{/if}}>{{#if known}}<a href="?view=docs">Docs</a>{{/if}}{{#unless known}}<a class="link-unavailable" title="This domain is not known by the search engine">Docs</a>{{/unless}}</li>
<li {{#if links}}class="current"{{/if}}><a href="?view=links">Links</a></li>
<li {{#if browse}}class="current"{{/if}}><a href="?view=similar">Similar</a></li>
</ul>
</nav>
{{/with}}
{{#if view.info}}{{#with domainInformation}}
<section id="siteinfo">
{{> search/site-info/site-info-screenshot}}
{{> search/site-info/site-info-index}}
{{> search/site-info/site-info-links}}
</section>
{{/with}}{{/if}}
{{#if view.links}}
<div class="infobox">
Showing search results with links to {{domain}}.
</div>
{{#each results}}{{>search/parts/search-result}}{{/each}}
{{/if}}
{{#if view.docs}}
<div class="infobox">
Showing documents found in {{domain}}.
</div>
{{#each results}}{{>search/parts/search-result}}{{/each}}
{{/if}}
{{#if view.report}}
{{>search/site-info/site-info-report}}
{{/if}}
{{>search/parts/search-footer}}
</body>

View File

@ -1,38 +0,0 @@
package nu.marginalia.search.command.commands;
import nu.marginalia.search.exceptions.RedirectException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.fail;
class BangCommandTest {
@Test
public void testBang() {
var bc = new BangCommand();
expectRedirectUrl("https://www.google.com/search?q=search+terms", () -> bc.process(null, null, "search terms !g"));
expectNoRedirect(() -> bc.process(null, null, "search terms!g"));
expectNoRedirect(() -> bc.process(null, null, "!gsearch terms"));
expectRedirectUrl("https://www.google.com/search?q=search+terms", () -> bc.process(null, null, "!g search terms"));
}
void expectNoRedirect(Runnable op) {
try {
op.run();
}
catch (RedirectException ex) {
fail("Expected no redirection, but got " + ex.newUrl);
}
}
void expectRedirectUrl(String expectedUrl, Runnable op) {
try {
op.run();
fail("Didn't intercept exception");
}
catch (RedirectException ex) {
Assertions.assertEquals(expectedUrl, ex.newUrl, "Unexpected redirect");
}
}
}