From: David ‘Bombe’ Roden Date: Sat, 3 Jun 2017 14:34:51 +0000 (+0200) Subject: Replace search page with Kotlin version, add more tests X-Git-Tag: 0.9.7^2~185 X-Git-Url: https://git.pterodactylus.net/?p=Sone.git;a=commitdiff_plain;h=e7319b53b914961d59b8fce999da75adfab54009 Replace search page with Kotlin version, add more tests --- diff --git a/src/main/java/net/pterodactylus/sone/web/pages/SearchPage.java b/src/main/java/net/pterodactylus/sone/web/pages/SearchPage.java deleted file mode 100644 index 534499e..0000000 --- a/src/main/java/net/pterodactylus/sone/web/pages/SearchPage.java +++ /dev/null @@ -1,661 +0,0 @@ -/* - * Sone - SearchPage.java - Copyright © 2010–2016 David Roden - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package net.pterodactylus.sone.web.pages; - -import static com.google.common.base.Optional.fromNullable; -import static com.google.common.primitives.Ints.tryParse; -import static java.util.logging.Logger.getLogger; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.logging.Level; -import java.util.logging.Logger; - -import net.pterodactylus.sone.data.Post; -import net.pterodactylus.sone.data.PostReply; -import net.pterodactylus.sone.data.Profile; -import net.pterodactylus.sone.data.Profile.Field; -import net.pterodactylus.sone.data.Reply; -import net.pterodactylus.sone.data.Sone; -import net.pterodactylus.sone.utils.Pagination; -import net.pterodactylus.sone.web.WebInterface; -import net.pterodactylus.sone.web.page.FreenetRequest; -import net.pterodactylus.util.template.Template; -import net.pterodactylus.util.template.TemplateContext; -import net.pterodactylus.util.text.StringEscaper; -import net.pterodactylus.util.text.TextException; - -import com.google.common.base.Function; -import com.google.common.base.Optional; -import com.google.common.base.Predicate; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; -import com.google.common.collect.Collections2; -import com.google.common.collect.FluentIterable; -import com.google.common.collect.Ordering; - -/** - * This page lets the user search for posts and replies that contain certain - * words. - * - * @author David ‘Bombe’ Roden - */ -public class SearchPage extends SoneTemplatePage { - - /** The logger. */ - private static final Logger logger = getLogger(SearchPage.class.getName()); - - /** Short-term cache. */ - private final LoadingCache, Set>> hitCache = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build(new CacheLoader, Set>>() { - - @Override - @SuppressWarnings("synthetic-access") - public Set> load(List phrases) { - Set posts = new HashSet(); - for (Sone sone : webInterface.getCore().getSones()) { - posts.addAll(sone.getPosts()); - } - return getHits(Collections2.filter(posts, Post.FUTURE_POSTS_FILTER), phrases, new PostStringGenerator()); - } - }); - - /** - * Creates a new search page. - * - * @param template - * The template to render - * @param webInterface - * The Sone web interface - */ - public SearchPage(Template template, WebInterface webInterface) { - super("search.html", template, "Page.Search.Title", webInterface); - } - - // - // SONETEMPLATEPAGE METHODS - // - - /** - * {@inheritDoc} - */ - @Override - @SuppressWarnings("synthetic-access") - protected void handleRequest(FreenetRequest request, TemplateContext templateContext) throws RedirectException { - String query = request.getHttpRequest().getParam("query").trim(); - if (query.length() == 0) { - throw new RedirectException("index.html"); - } - - List phrases = parseSearchPhrases(query); - if (phrases.isEmpty()) { - throw new RedirectException("index.html"); - } - - /* check for a couple of shortcuts. */ - if (phrases.size() == 1) { - String phrase = phrases.get(0).getPhrase(); - - /* is it a Sone ID? */ - redirectIfNotNull(getSoneId(phrase), "viewSone.html?sone="); - - /* is it a post ID? */ - redirectIfNotNull(getPostId(phrase), "viewPost.html?post="); - - /* is it a reply ID? show the post. */ - redirectIfNotNull(getReplyPostId(phrase), "viewPost.html?post="); - - /* is it an album ID? */ - redirectIfNotNull(getAlbumId(phrase), "imageBrowser.html?album="); - - /* is it an image ID? */ - redirectIfNotNull(getImageId(phrase), "imageBrowser.html?image="); - } - - Collection sones = webInterface.getCore().getSones(); - Collection> soneHits = getHits(sones, phrases, SoneStringGenerator.COMPLETE_GENERATOR); - - Collection> postHits = hitCache.getUnchecked(phrases); - - /* now filter. */ - soneHits = Collections2.filter(soneHits, Hit.POSITIVE_FILTER); - postHits = Collections2.filter(postHits, Hit.POSITIVE_FILTER); - - /* now sort. */ - List> sortedSoneHits = Ordering.from(Hit.DESCENDING_COMPARATOR).sortedCopy(soneHits); - List> sortedPostHits = Ordering.from(Hit.DESCENDING_COMPARATOR).sortedCopy(postHits); - - /* extract Sones and posts. */ - List resultSones = FluentIterable.from(sortedSoneHits).transform(new HitMapper()).toList(); - List resultPosts = FluentIterable.from(sortedPostHits).transform(new HitMapper()).toList(); - - /* pagination. */ - Pagination sonePagination = new Pagination(resultSones, webInterface.getCore().getPreferences().getPostsPerPage()); - sonePagination.setPage(fromNullable(tryParse(request.getHttpRequest().getParam("sonePage"))).or(0)); - Pagination postPagination = new Pagination(resultPosts, webInterface.getCore().getPreferences().getPostsPerPage()); - postPagination.setPage(fromNullable(tryParse(request.getHttpRequest().getParam("postPage"))).or(0)); - - templateContext.set("sonePagination", sonePagination); - templateContext.set("soneHits", sonePagination.getItems()); - templateContext.set("postPagination", postPagination); - templateContext.set("postHits", postPagination.getItems()); - } - - // - // PRIVATE METHODS - // - - /** - * Collects hit information for the given objects. The objects are converted - * to a {@link String} using the given {@link StringGenerator}, and the - * {@link #calculateScore(List, String) calculated score} is stored together - * with the object in a {@link Hit}, and all resulting {@link Hit}s are then - * returned. - * - * @param - * The type of the objects - * @param objects - * The objects to search over - * @param phrases - * The phrases to search for - * @param stringGenerator - * The string generator for the objects - * @return The hits for the given phrases - */ - private static Set> getHits(Collection objects, List phrases, StringGenerator stringGenerator) { - Set> hits = new HashSet>(); - for (T object : objects) { - String objectString = stringGenerator.generateString(object); - double score = calculateScore(phrases, objectString); - hits.add(new Hit(object, score)); - } - return hits; - } - - /** - * Parses the given query into search phrases. The query is split on - * whitespace while allowing to group words using single or double quotes. - * Isolated phrases starting with a “+” are - * {@link Phrase.Optionality#REQUIRED}, phrases with a “-” are - * {@link Phrase.Optionality#FORBIDDEN}. - * - * @param query - * The query to parse - * @return The parsed phrases - */ - private static List parseSearchPhrases(String query) { - List parsedPhrases; - try { - parsedPhrases = StringEscaper.parseLine(query); - } catch (TextException te1) { - /* invalid query. */ - return Collections.emptyList(); - } - - List phrases = new ArrayList(); - for (String phrase : parsedPhrases) { - if (phrase.startsWith("+")) { - if (phrase.length() > 1) { - phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.REQUIRED)); - } else { - phrases.add(new Phrase("+", Phrase.Optionality.OPTIONAL)); - } - } else if (phrase.startsWith("-")) { - if (phrase.length() > 1) { - phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.FORBIDDEN)); - } else { - phrases.add(new Phrase("-", Phrase.Optionality.OPTIONAL)); - } - } else { - phrases.add(new Phrase(phrase, Phrase.Optionality.OPTIONAL)); - } - } - return phrases; - } - - /** - * Calculates the score for the given expression when using the given - * phrases. - * - * @param phrases - * The phrases to search for - * @param expression - * The expression to search - * @return The score of the expression - */ - private static double calculateScore(List phrases, String expression) { - logger.log(Level.FINEST, String.format("Calculating Score for “%s”…", expression)); - double optionalHits = 0; - double requiredHits = 0; - int forbiddenHits = 0; - int requiredPhrases = 0; - for (Phrase phrase : phrases) { - String phraseString = phrase.getPhrase().toLowerCase(); - if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) { - ++requiredPhrases; - } - int matches = 0; - int index = 0; - double score = 0; - while (index < expression.length()) { - int position = expression.toLowerCase().indexOf(phraseString, index); - if (position == -1) { - break; - } - score += Math.pow(1 - position / (double) expression.length(), 2); - index = position + phraseString.length(); - logger.log(Level.FINEST, String.format("Got hit at position %d.", position)); - ++matches; - } - logger.log(Level.FINEST, String.format("Score: %f", score)); - if (matches == 0) { - continue; - } - if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) { - requiredHits += score; - } - if (phrase.getOptionality() == Phrase.Optionality.OPTIONAL) { - optionalHits += score; - } - if (phrase.getOptionality() == Phrase.Optionality.FORBIDDEN) { - forbiddenHits += matches; - } - } - return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2); - } - - /** - * Throws a - * {@link net.pterodactylus.sone.web.page.FreenetTemplatePage.RedirectException} - * if the given object is not {@code null}, appending the object to the - * given target URL. - * - * @param object - * The object on which to redirect - * @param target - * The target of the redirect - * @throws RedirectException - * if {@code object} is not {@code null} - */ - private static void redirectIfNotNull(String object, String target) throws RedirectException { - if (object != null) { - throw new RedirectException(target + object); - } - } - - /** - * If the given phrase contains a Sone ID (optionally prefixed by - * “sone://”), returns said Sone ID, otherwise return {@code null}. - * - * @param phrase - * The phrase that maybe is a Sone ID - * @return The Sone ID, or {@code null} - */ - private String getSoneId(String phrase) { - String soneId = phrase.startsWith("sone://") ? phrase.substring(7) : phrase; - return (webInterface.getCore().getSone(soneId).isPresent()) ? soneId : null; - } - - /** - * If the given phrase contains a post ID (optionally prefixed by - * “post://”), returns said post ID, otherwise return {@code null}. - * - * @param phrase - * The phrase that maybe is a post ID - * @return The post ID, or {@code null} - */ - private String getPostId(String phrase) { - String postId = phrase.startsWith("post://") ? phrase.substring(7) : phrase; - return (webInterface.getCore().getPost(postId).isPresent()) ? postId : null; - } - - /** - * If the given phrase contains a reply ID (optionally prefixed by - * “reply://”), returns the ID of the post the reply belongs to, otherwise - * return {@code null}. - * - * @param phrase - * The phrase that maybe is a reply ID - * @return The reply’s post ID, or {@code null} - */ - private String getReplyPostId(String phrase) { - String replyId = phrase.startsWith("reply://") ? phrase.substring(8) : phrase; - Optional postReply = webInterface.getCore().getPostReply(replyId); - if (!postReply.isPresent()) { - return null; - } - return postReply.get().getPostId(); - } - - /** - * If the given phrase contains an album ID (optionally prefixed by - * “album://”), returns said album ID, otherwise return {@code null}. - * - * @param phrase - * The phrase that maybe is an album ID - * @return The album ID, or {@code null} - */ - private String getAlbumId(String phrase) { - String albumId = phrase.startsWith("album://") ? phrase.substring(8) : phrase; - return (webInterface.getCore().getAlbum(albumId) != null) ? albumId : null; - } - - /** - * If the given phrase contains an image ID (optionally prefixed by - * “image://”), returns said image ID, otherwise return {@code null}. - * - * @param phrase - * The phrase that maybe is an image ID - * @return The image ID, or {@code null} - */ - private String getImageId(String phrase) { - String imageId = phrase.startsWith("image://") ? phrase.substring(8) : phrase; - return (webInterface.getCore().getImage(imageId, false) != null) ? imageId : null; - } - - /** - * Converts a given object into a {@link String}. - * - * @param - * The type of the objects - * @author David ‘Bombe’ Roden - */ - private static interface StringGenerator { - - /** - * Generates a {@link String} for the given object. - * - * @param object - * The object to generate the {@link String} for - * @return The generated {@link String} - */ - public String generateString(T object); - - } - - /** - * Generates a {@link String} from a {@link Sone}, concatenating the name of - * the Sone and all {@link Profile} {@link Field} values. - * - * @author David ‘Bombe’ Roden - */ - private static class SoneStringGenerator implements StringGenerator { - - /** A static instance of a complete Sone string generator. */ - public static final SoneStringGenerator COMPLETE_GENERATOR = new SoneStringGenerator(true); - - /** - * A static instance of a Sone string generator that will only use the - * name of the Sone. - */ - public static final SoneStringGenerator NAME_GENERATOR = new SoneStringGenerator(false); - - /** Whether to generate a string from all data of a Sone. */ - private final boolean complete; - - /** - * Creates a new Sone string generator. - * - * @param complete - * {@code true} to use the profile’s fields, {@code false} to - * not to use the profile‘s fields - */ - private SoneStringGenerator(boolean complete) { - this.complete = complete; - } - - /** - * {@inheritDoc} - */ - @Override - public String generateString(Sone sone) { - StringBuilder soneString = new StringBuilder(); - soneString.append(sone.getName()); - Profile soneProfile = sone.getProfile(); - if (soneProfile.getFirstName() != null) { - soneString.append(' ').append(soneProfile.getFirstName()); - } - if (soneProfile.getMiddleName() != null) { - soneString.append(' ').append(soneProfile.getMiddleName()); - } - if (soneProfile.getLastName() != null) { - soneString.append(' ').append(soneProfile.getLastName()); - } - if (complete) { - for (Field field : soneProfile.getFields()) { - soneString.append(' ').append(field.getValue()); - } - } - return soneString.toString(); - } - - } - - /** - * Generates a {@link String} from a {@link Post}, concatenating the text of - * the post, the text of all {@link Reply}s, and the name of all - * {@link Sone}s that have replied. - * - * @author David ‘Bombe’ Roden - */ - private class PostStringGenerator implements StringGenerator { - - /** - * {@inheritDoc} - */ - @Override - public String generateString(Post post) { - StringBuilder postString = new StringBuilder(); - postString.append(post.getText()); - if (post.getRecipient().isPresent()) { - postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(post.getRecipient().get())); - } - for (PostReply reply : Collections2.filter(webInterface.getCore().getReplies(post.getId()), Reply.FUTURE_REPLY_FILTER)) { - postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(reply.getSone())); - postString.append(' ').append(reply.getText()); - } - return postString.toString(); - } - - } - - /** - * A search phrase. - * - * @author David ‘Bombe’ Roden - */ - private static class Phrase { - - /** - * The optionality of a search phrase. - * - * @author David ‘Bombe’ - * Roden - */ - public enum Optionality { - - /** The phrase is optional. */ - OPTIONAL, - - /** The phrase is required. */ - REQUIRED, - - /** The phrase is forbidden. */ - FORBIDDEN - - } - - /** The phrase to search for. */ - private final String phrase; - - /** The optionality of the phrase. */ - private final Optionality optionality; - - /** - * Creates a new phrase. - * - * @param phrase - * The phrase to search for - * @param optionality - * The optionality of the phrase - */ - public Phrase(String phrase, Optionality optionality) { - this.optionality = optionality; - this.phrase = phrase; - } - - /** - * Returns the phrase to search for. - * - * @return The phrase to search for - */ - public String getPhrase() { - return phrase; - } - - /** - * Returns the optionality of the phrase. - * - * @return The optionality of the phrase - */ - public Optionality getOptionality() { - return optionality; - } - - // - // OBJECT METHODS - // - - /** - * {@inheritDoc} - */ - @Override - public int hashCode() { - return phrase.hashCode() ^ ((optionality == Optionality.FORBIDDEN) ? (0xaaaaaaaa) : ((optionality == Optionality.REQUIRED) ? 0x55555555 : 0)); - } - - /** - * {@inheritDoc} - */ - @Override - public boolean equals(Object object) { - if (!(object instanceof Phrase)) { - return false; - } - Phrase phrase = (Phrase) object; - return (this.optionality == phrase.optionality) && this.phrase.equals(phrase.phrase); - } - - } - - /** - * A hit consists of a searched object and the score it got for the phrases - * of the search. - * - * @see SearchPage#calculateScore(List, String) - * @param - * The type of the searched object - * @author David ‘Bombe’ Roden - */ - private static class Hit { - - /** Filter for {@link Hit}s with a score of more than 0. */ - public static final Predicate> POSITIVE_FILTER = new Predicate>() { - - @Override - public boolean apply(Hit hit) { - return (hit != null) && (hit.getScore() > 0); - } - - }; - - /** Comparator that sorts {@link Hit}s descending by score. */ - public static final Comparator> DESCENDING_COMPARATOR = new Comparator>() { - - @Override - public int compare(Hit leftHit, Hit rightHit) { - return Double.compare(rightHit.getScore(), leftHit.getScore()); - } - - }; - - /** The object that was searched. */ - private final T object; - - /** The score of the object. */ - private final double score; - - /** - * Creates a new hit. - * - * @param object - * The object that was searched - * @param score - * The score of the object - */ - public Hit(T object, double score) { - this.object = object; - this.score = score; - } - - /** - * Returns the object that was searched. - * - * @return The object that was searched - */ - public T getObject() { - return object; - } - - /** - * Returns the score of the object. - * - * @return The score of the object - */ - public double getScore() { - return score; - } - - } - - /** - * Extracts the object from a {@link Hit}. - * - * @param - * The type of the object to extract - * @author David ‘Bombe’ Roden - */ - private static class HitMapper implements Function, T> { - - /** - * {@inheritDoc} - */ - @Override - public T apply(Hit input) { - return input.getObject(); - } - - } - -} diff --git a/src/main/kotlin/net/pterodactylus/sone/web/pages/SearchPage.kt b/src/main/kotlin/net/pterodactylus/sone/web/pages/SearchPage.kt new file mode 100644 index 0000000..321146a --- /dev/null +++ b/src/main/kotlin/net/pterodactylus/sone/web/pages/SearchPage.kt @@ -0,0 +1,145 @@ +package net.pterodactylus.sone.web.pages + +import com.google.common.base.Ticker +import com.google.common.cache.Cache +import com.google.common.cache.CacheBuilder +import net.pterodactylus.sone.data.Post +import net.pterodactylus.sone.data.PostReply +import net.pterodactylus.sone.data.Sone +import net.pterodactylus.sone.utils.Pagination +import net.pterodactylus.sone.utils.emptyToNull +import net.pterodactylus.sone.utils.paginate +import net.pterodactylus.sone.utils.parameters +import net.pterodactylus.sone.web.WebInterface +import net.pterodactylus.sone.web.page.FreenetRequest +import net.pterodactylus.sone.web.pages.SearchPage.Optionality.FORBIDDEN +import net.pterodactylus.sone.web.pages.SearchPage.Optionality.OPTIONAL +import net.pterodactylus.sone.web.pages.SearchPage.Optionality.REQUIRED +import net.pterodactylus.util.template.Template +import net.pterodactylus.util.template.TemplateContext +import net.pterodactylus.util.text.StringEscaper +import net.pterodactylus.util.text.TextException +import java.util.concurrent.TimeUnit.MINUTES + +/** + * This page lets the user search for posts and replies that contain certain + * words. + */ +class SearchPage @JvmOverloads constructor(template: Template, webInterface: WebInterface, ticker: Ticker = Ticker.systemTicker()): + SoneTemplatePage("search.html", template, "Page.Search.Title", webInterface, false) { + + private val cache: Cache, Pagination> = CacheBuilder.newBuilder().ticker(ticker).expireAfterAccess(5, MINUTES).build() + + override fun handleRequest(request: FreenetRequest, templateContext: TemplateContext) { + val phrases = try { + request.parameters["query"].emptyToNull?.parse() + } catch (te: TextException) { + redirect("index.html") + } + ?: redirect("index.html") + + when (phrases.size) { + 0 -> redirect("index.html") + 1 -> phrases.first().phrase.also { word -> + when { + word.removePrefix("sone://").let(webInterface.core::getSone).isPresent -> redirect("viewSone.html?sone=${word.removePrefix("sone://")}") + word.removePrefix("post://").let(webInterface.core::getPost).isPresent -> redirect("viewPost.html?post=${word.removePrefix("post://")}") + word.removePrefix("reply://").let(webInterface.core::getPostReply).isPresent -> redirect("viewPost.html?post=${word.removePrefix("reply://").let(webInterface.core::getPostReply).get().postId}") + word.removePrefix("album://").let(webInterface.core::getAlbum) != null -> redirect("imageBrowser.html?album=${word.removePrefix("album://")}") + word.removePrefix("image://").let { webInterface.core.getImage(it, false) } != null -> redirect("imageBrowser.html?image=${word.removePrefix("image://")}") + } + } + } + + val sonePagination = webInterface.core.sones + .scoreAndPaginate(phrases) { it.allText() } + .apply { page = request.parameters["sonePage"].emptyToNull?.toIntOrNull() ?: 0 } + val postPagination = cache.get(phrases) { + webInterface.core.sones + .flatMap(Sone::getPosts) + .filter { Post.FUTURE_POSTS_FILTER.apply(it) } + .scoreAndPaginate(phrases) { it.allText() } + }.apply { page = request.parameters["postPage"].emptyToNull?.toIntOrNull() ?: 0 } + + templateContext["sonePagination"] = sonePagination + templateContext["soneHits"] = sonePagination.items + templateContext["postPagination"] = postPagination + templateContext["postHits"] = postPagination.items + } + + private fun Iterable.scoreAndPaginate(phrases: Iterable, texter: (T) -> String) = + map { it to score(texter(it), phrases) } + .filter { it.second > 0 } + .sortedByDescending { it.second } + .map { it.first } + .paginate(webInterface.core.preferences.postsPerPage) + + private fun Sone.names() = + listOf(name, profile.firstName, profile.middleName, profile.lastName) + .filterNotNull() + .joinToString("") + + private fun Sone.allText() = + (names() + profile.fields.map { "${it.name} ${it.value}" }.joinToString(" ", " ")).toLowerCase() + + private fun Post.allText() = + (text + recipient.orNull()?.let { " ${it.names()}" } + webInterface.core.getReplies(id) + .filter { PostReply.FUTURE_REPLY_FILTER.apply(it) } + .map { "${it.sone.names()} ${it.text}" }.joinToString(" ", " ")).toLowerCase() + + private fun score(text: String, phrases: Iterable): Double { + val requiredPhrases = phrases.count { it.required } + val requiredHits = phrases.filter(Phrase::required) + .map(Phrase::phrase) + .flatMap { text.findAll(it) } + .map { Math.pow(1 - it / text.length.toDouble(), 2.0) } + .sum() + val optionalHits = phrases.filter(Phrase::optional) + .map(Phrase::phrase) + .flatMap { text.findAll(it) } + .map { Math.pow(1 - it / text.length.toDouble(), 2.0) } + .sum() + val forbiddenHits = phrases.filter(Phrase::forbidden) + .map(Phrase::phrase) + .map { text.findAll(it).size } + .sum() + return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2) + } + + private fun String.findAll(needle: String): List { + var nextIndex = indexOf(needle) + val positions = mutableListOf() + while (nextIndex != -1) { + positions += nextIndex + nextIndex = indexOf(needle, nextIndex + 1) + } + return positions + } + + private fun String.parse() = + StringEscaper.parseLine(this) + .map(String::toLowerCase) + .map { + when { + it == "+" || it == "-" -> Phrase(it, OPTIONAL) + it.startsWith("+") -> Phrase(it.drop(1), REQUIRED) + it.startsWith("-") -> Phrase(it.drop(1), FORBIDDEN) + else -> Phrase(it, OPTIONAL) + } + } + + private fun redirect(target: String): Nothing = throw RedirectException(target) + + enum class Optionality { + OPTIONAL, + REQUIRED, + FORBIDDEN + } + + private data class Phrase(val phrase: String, val optionality: Optionality) { + val required = optionality == REQUIRED + val forbidden = optionality == FORBIDDEN + val optional = optionality == OPTIONAL + } + +} diff --git a/src/test/kotlin/net/pterodactylus/sone/web/pages/SearchPageTest.kt b/src/test/kotlin/net/pterodactylus/sone/web/pages/SearchPageTest.kt index 4f84c87..a77ffcc 100644 --- a/src/test/kotlin/net/pterodactylus/sone/web/pages/SearchPageTest.kt +++ b/src/test/kotlin/net/pterodactylus/sone/web/pages/SearchPageTest.kt @@ -1,6 +1,7 @@ package net.pterodactylus.sone.web.pages import com.google.common.base.Optional.absent +import com.google.common.base.Ticker import net.pterodactylus.sone.data.Album import net.pterodactylus.sone.data.Image import net.pterodactylus.sone.data.Post @@ -11,19 +12,20 @@ import net.pterodactylus.sone.test.asOptional import net.pterodactylus.sone.test.isOnPage import net.pterodactylus.sone.test.mock import net.pterodactylus.sone.test.whenever -import net.pterodactylus.sone.utils.Pagination -import net.pterodactylus.util.template.TemplateContext import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.equalTo import org.junit.Test +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger /** * Unit test for [SearchPage]. */ class SearchPageTest : WebPageTest() { - private val page = SearchPage(template, webInterface) + private val ticker = mock() + private val page = SearchPage(template, webInterface, ticker) override fun getPage() = page @@ -327,6 +329,39 @@ class SearchPageTest : WebPageTest() { } } + @Test + fun `post search results are cached`() { + val post = createPost("with-match", "text") + val callCounter = AtomicInteger() + whenever(post.text).thenAnswer { callCounter.incrementAndGet(); "text" } + val sone = createSoneWithPost(post) + addSone("sone", sone) + addHttpRequestParameter("query", "text") + verifyNoRedirect { + assertThat(this["postHits"], contains(post)) + } + verifyNoRedirect { + assertThat(callCounter.get(), equalTo(1)) + } + } + + @Test + fun `post search results are cached for five minutes`() { + val post = createPost("with-match", "text") + val callCounter = AtomicInteger() + whenever(post.text).thenAnswer { callCounter.incrementAndGet(); "text" } + val sone = createSoneWithPost(post) + addSone("sone", sone) + addHttpRequestParameter("query", "text") + verifyNoRedirect { + assertThat(this["postHits"], contains(post)) + } + whenever(ticker.read()).thenReturn(TimeUnit.MINUTES.toNanos(5) + 1 ) + verifyNoRedirect { + assertThat(callCounter.get(), equalTo(2)) + } + } + @Suppress("UNCHECKED_CAST") private operator fun get(key: String): T? = templateContext[key] as? T