From c67605d6ee939160420d176cba9571f442c14978 Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20=E2=80=98Bombe=E2=80=99=20Roden?= Date: Fri, 1 Apr 2011 16:04:24 +0200 Subject: [PATCH] Implement search. This fixes #5. --- .../net/pterodactylus/sone/web/SearchPage.java | 447 +++++++++++++++++++++ .../net/pterodactylus/sone/web/WebInterface.java | 2 + src/main/resources/i18n/sone.en.properties | 9 + src/main/resources/static/css/sone.css | 13 + src/main/resources/static/javascript/sone.js | 5 + src/main/resources/templates/include/head.html | 4 + src/main/resources/templates/search.html | 35 ++ 7 files changed, 515 insertions(+) create mode 100644 src/main/java/net/pterodactylus/sone/web/SearchPage.java create mode 100644 src/main/resources/templates/search.html diff --git a/src/main/java/net/pterodactylus/sone/web/SearchPage.java b/src/main/java/net/pterodactylus/sone/web/SearchPage.java new file mode 100644 index 0000000..bd0354f --- /dev/null +++ b/src/main/java/net/pterodactylus/sone/web/SearchPage.java @@ -0,0 +1,447 @@ +/* + * Sone - OptionsPage.java - Copyright © 2010 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; + +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 net.pterodactylus.sone.data.Post; +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.util.collection.Converter; +import net.pterodactylus.util.collection.Converters; +import net.pterodactylus.util.collection.Pagination; +import net.pterodactylus.util.filter.Filter; +import net.pterodactylus.util.filter.Filters; +import net.pterodactylus.util.number.Numbers; +import net.pterodactylus.util.template.Template; +import net.pterodactylus.util.template.TemplateContext; +import net.pterodactylus.util.text.StringEscaper; +import net.pterodactylus.util.text.TextException; + +/** + * This page lets the user search for posts and replies that contain certain + * words. + * + * @author David ‘Bombe’ Roden + */ +public class SearchPage extends SoneTemplatePage { + + /** + * 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 + // + + @Override + protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException { + super.processTemplate(request, templateContext); + String query = request.getHttpRequest().getParam("query").trim(); + if (query.length() == 0) { + throw new RedirectException("index.html"); + } + + List phrases = parseSearchPhrases(query); + + Set sones = webInterface.getCore().getSones(); + Set> soneHits = getHits(sones, phrases, SoneStringGenerator.GENERATOR); + + Set posts = new HashSet(); + for (Sone sone : sones) { + posts.addAll(sone.getPosts()); + } + @SuppressWarnings("synthetic-access") + Set> postHits = getHits(posts, phrases, new PostStringGenerator()); + + /* now filter. */ + soneHits = Filters.filteredSet(soneHits, Hit.POSITIVE_FILTER); + postHits = Filters.filteredSet(postHits, Hit.POSITIVE_FILTER); + + /* now sort. */ + List> sortedSoneHits = new ArrayList>(soneHits); + Collections.sort(sortedSoneHits, Hit.DESCENDING_COMPARATOR); + List> sortedPostHits = new ArrayList>(postHits); + Collections.sort(sortedPostHits, Hit.DESCENDING_COMPARATOR); + + /* extract Sones and posts. */ + List resultSones = Converters.convertList(sortedSoneHits, new HitConverter()); + List resultPosts = Converters.convertList(sortedPostHits, new HitConverter()); + + /* pagination. */ + Pagination sonePagination = new Pagination(resultSones, 10).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("sonePage"), 0)); + Pagination postPagination = new Pagination(resultPosts, 10).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("postPage"), 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 Set> getHits(Collection objects, List phrases, StringGenerator stringGenerator) { + Set> hits = new HashSet>(); + for (T object : objects) { + String objectString = stringGenerator.generateString(object); + int 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 List parseSearchPhrases(String query) { + List parsedPhrases = null; + try { + parsedPhrases = StringEscaper.parseLine(query); + } catch (TextException te1) { + /* invalid query. */ + return Collections.emptyList(); + } + + List phrases = new ArrayList(); + for (String phrase : parsedPhrases) { + if (phrase.startsWith("+")) { + phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.REQUIRED)); + } else if (phrase.startsWith("-")) { + phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.FORBIDDEN)); + } + 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 int calculateScore(List phrases, String expression) { + int optionalHits = 0; + int requiredHits = 0; + int forbiddenHits = 0; + int requiredPhrases = 0; + for (Phrase phrase : phrases) { + if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) { + ++requiredPhrases; + } + boolean matches = expression.toLowerCase().contains(phrase.getPhrase().toLowerCase()); + if (!matches) { + continue; + } + if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) { + ++requiredHits; + } + if (phrase.getOptionality() == Phrase.Optionality.OPTIONAL) { + ++optionalHits; + } + if (phrase.getOptionality() == Phrase.Optionality.FORBIDDEN) { + ++forbiddenHits; + } + } + return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2); + } + + /** + * 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 the Sone string generator. */ + public static final SoneStringGenerator GENERATOR = new SoneStringGenerator(); + + /** + * {@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()); + } + 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() != null) { + postString.append(' ').append(SoneStringGenerator.GENERATOR.generateString(post.getRecipient())); + } + for (Reply reply : webInterface.getCore().getReplies(post)) { + postString.append(' ').append(SoneStringGenerator.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; + } + + } + + /** + * 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 Filter> POSITIVE_FILTER = new Filter>() { + + @Override + public boolean filterObject(Hit hit) { + return 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 rightHit.getScore() - leftHit.getScore(); + } + + }; + + /** The object that was searched. */ + private final T object; + + /** The score of the object. */ + private final int score; + + /** + * Creates a new hit. + * + * @param object + * The object that was searched + * @param score + * The score of the object + */ + public Hit(T object, int 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 int getScore() { + return score; + } + + } + + /** + * Extracts the object from a {@link Hit}. + * + * @param + * The type of the object to extract + * @author David ‘Bombe’ Roden + */ + public static class HitConverter implements Converter, T> { + + @Override + public T convert(Hit input) { + return input.getObject(); + } + + } + +} diff --git a/src/main/java/net/pterodactylus/sone/web/WebInterface.java b/src/main/java/net/pterodactylus/sone/web/WebInterface.java index 89cec92..811fc39 100644 --- a/src/main/java/net/pterodactylus/sone/web/WebInterface.java +++ b/src/main/java/net/pterodactylus/sone/web/WebInterface.java @@ -523,6 +523,7 @@ public class WebInterface implements CoreListener { Template createPostTemplate = TemplateParser.parse(createReader("/templates/createPost.html")); Template createReplyTemplate = TemplateParser.parse(createReader("/templates/createReply.html")); Template bookmarksTemplate = TemplateParser.parse(createReader("/templates/bookmarks.html")); + Template searchTemplate = TemplateParser.parse(createReader("/templates/search.html")); Template editProfileTemplate = TemplateParser.parse(createReader("/templates/editProfile.html")); Template editProfileFieldTemplate = TemplateParser.parse(createReader("/templates/editProfileField.html")); Template deleteProfileFieldTemplate = TemplateParser.parse(createReader("/templates/deleteProfileField.html")); @@ -564,6 +565,7 @@ public class WebInterface implements CoreListener { pageToadlets.add(pageToadletFactory.createPageToadlet(new BookmarkPage(emptyTemplate, this))); pageToadlets.add(pageToadletFactory.createPageToadlet(new UnbookmarkPage(emptyTemplate, this))); pageToadlets.add(pageToadletFactory.createPageToadlet(new BookmarksPage(bookmarksTemplate, this), "Bookmarks")); + pageToadlets.add(pageToadletFactory.createPageToadlet(new SearchPage(searchTemplate, this))); pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteSonePage(deleteSoneTemplate, this), "DeleteSone")); pageToadlets.add(pageToadletFactory.createPageToadlet(new LoginPage(loginTemplate, this), "Login")); pageToadlets.add(pageToadletFactory.createPageToadlet(new LogoutPage(emptyTemplate, this), "Logout")); diff --git a/src/main/resources/i18n/sone.en.properties b/src/main/resources/i18n/sone.en.properties index 9b48cad..c8604cb 100644 --- a/src/main/resources/i18n/sone.en.properties +++ b/src/main/resources/i18n/sone.en.properties @@ -169,6 +169,12 @@ Page.Bookmarks.Page.Title=Bookmarks Page.Bookmarks.Text.NoBookmarks=You don’t have any bookmarks defined right now. You can bookmark posts by clicking the star below the post. Page.Bookmarks.Text.PostsNotLoaded=Some of your bookmarked posts have not been shown because they could not be loaded. This can happen if you restarted Sone recently or if the originating Sone has deleted the post. If you are reasonable sure that these posts do not exist anymore, you can {link}unbookmark them{/link}. +Page.Search.Title=Search - Sone +Page.Search.Page.Title=Search Results +Page.Search.Text.SoneHits=The following Sones match your search terms. +Page.Search.Text.PostHits=The following posts match your search terms. +Page.Search.Text.NoHits=No Sones or posts matched your search terms. + Page.NoPermission.Title=Unauthorized Access - Sone Page.NoPermission.Page.Title=Unauthorized Access Page.NoPermission.Text.NoPermission=You tried to do something that you do not have sufficient authorization for. Please refrain from such actions in the future or we will be forced to take counter-measures! @@ -184,6 +190,8 @@ Page.Invalid.Title=Invalid Action Performed Page.Invalid.Page.Title=Invalid Action Performed Page.Invalid.Text=An invalid action was performed, or the action was valid but the parameters were not. Please go back to the {link}index page{/link} and try again. If the error persists you have probably found a bug. +View.Search.Button.Search=Search + View.CreateSone.Text.WotIdentityRequired=To create a Sone you need an identity from the {link}Web of Trust plugin{/link}. View.CreateSone.Select.Default=Select an identity View.CreateSone.Text.NoIdentities=You do not have any Web of Trust identities. Please head over to the {link}Web of Trust plugin{/link} and create an identity. @@ -232,6 +240,7 @@ WebInterface.DefaultText.BirthMonth=Month WebInterface.DefaultText.BirthYear=Year WebInterface.DefaultText.FieldName=Field name WebInterface.DefaultText.Option.InsertionDelay=Time to wait after a Sone is modified before insert (in seconds) +WebInterface.DefaultText.Search=What are you looking for? WebInterface.Confirmation.DeletePostButton=Yes, delete! WebInterface.Confirmation.DeleteReplyButton=Yes, delete! WebInterface.SelectBox.Choose=Choose… diff --git a/src/main/resources/static/css/sone.css b/src/main/resources/static/css/sone.css index 8eb8449..38fcd19 100644 --- a/src/main/resources/static/css/sone.css +++ b/src/main/resources/static/css/sone.css @@ -545,6 +545,19 @@ textarea { position: relative; } +#sone #search { + text-align: right; +} + +#sone #search input[type=text] { + width: 35em; +} + +#sone #sone-results + #sone #post-results { + clear: both; + padding-top: 1em; +} + #sone #tail { margin-top: 1em; border-top: solid 1px #ccc; diff --git a/src/main/resources/static/javascript/sone.js b/src/main/resources/static/javascript/sone.js index 96c8ad5..6022655 100644 --- a/src/main/resources/static/javascript/sone.js +++ b/src/main/resources/static/javascript/sone.js @@ -1322,6 +1322,11 @@ $(document).ready(function() { }); }); + /* ajaxify the search input field. */ + getTranslation("WebInterface.DefaultText.Search", function(defaultText) { + registerInputTextareaSwap("#sone #search input[name=query]", defaultText, "query", false, true); + }); + /* ajaxify input field on “view Sone” page. */ getTranslation("WebInterface.DefaultText.Message", function(defaultText) { registerInputTextareaSwap("#sone #post-message input[name=text]", defaultText, "text", false, false); diff --git a/src/main/resources/templates/include/head.html b/src/main/resources/templates/include/head.html index cd83600..fe9cf34 100644 --- a/src/main/resources/templates/include/head.html +++ b/src/main/resources/templates/include/head.html @@ -47,4 +47,8 @@ <%include include/viewSone.html> <%/if> + diff --git a/src/main/resources/templates/search.html b/src/main/resources/templates/search.html new file mode 100644 index 0000000..9f87072 --- /dev/null +++ b/src/main/resources/templates/search.html @@ -0,0 +1,35 @@ +<%include include/head.html> + +

<%= Page.Search.Page.Title|l10n|html>

+ + <%foreach soneHits sone> + <%first> +
+

<%= Page.Search.Text.SoneHits|l10n|html>

+ <%include include/pagination.html pagination=sonePagination pageParameter==sonePage> + <%/first> + <%include include/viewSone.html> + <%last> + <%include include/pagination.html pagination=sonePagination pageParameter==sonePage> +
+ <%/last> + <%/foreach> + + <%foreach postHits post> + <%first> +
+

<%= Page.Search.Text.PostHits|l10n|html>

+ <%include include/pagination.html pagination=postPagination pageParameter==postPage> + <%/first> + <%include include/viewPost.html> + <%last> + <%include include/pagination.html pagination=postPagination pageParameter==postPage> +
+ <%/last> + <%/foreach> + + <%if soneHits.empty><%if postHits.empty> +

<%= Page.Search.Text.NoHits|l10n|html>

+ <%/if><%/if> + +<%include include/tail.html> -- 2.7.4