Implement search.
[Sone.git] / src / main / java / net / pterodactylus / sone / web / SearchPage.java
1 /*
2  * Sone - OptionsPage.java - Copyright © 2010 David Roden
3  *
4  * This program is free software: you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation, either version 3 of the License, or
7  * (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12  * GNU General Public License for more details.
13  *
14  * You should have received a copy of the GNU General Public License
15  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
16  */
17
18 package net.pterodactylus.sone.web;
19
20 import java.util.ArrayList;
21 import java.util.Collection;
22 import java.util.Collections;
23 import java.util.Comparator;
24 import java.util.HashSet;
25 import java.util.List;
26 import java.util.Set;
27
28 import net.pterodactylus.sone.data.Post;
29 import net.pterodactylus.sone.data.Profile;
30 import net.pterodactylus.sone.data.Profile.Field;
31 import net.pterodactylus.sone.data.Reply;
32 import net.pterodactylus.sone.data.Sone;
33 import net.pterodactylus.util.collection.Converter;
34 import net.pterodactylus.util.collection.Converters;
35 import net.pterodactylus.util.collection.Pagination;
36 import net.pterodactylus.util.filter.Filter;
37 import net.pterodactylus.util.filter.Filters;
38 import net.pterodactylus.util.number.Numbers;
39 import net.pterodactylus.util.template.Template;
40 import net.pterodactylus.util.template.TemplateContext;
41 import net.pterodactylus.util.text.StringEscaper;
42 import net.pterodactylus.util.text.TextException;
43
44 /**
45  * This page lets the user search for posts and replies that contain certain
46  * words.
47  *
48  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
49  */
50 public class SearchPage extends SoneTemplatePage {
51
52         /**
53          * Creates a new search page.
54          *
55          * @param template
56          *            The template to render
57          * @param webInterface
58          *            The Sone web interface
59          */
60         public SearchPage(Template template, WebInterface webInterface) {
61                 super("search.html", template, "Page.Search.Title", webInterface);
62         }
63
64         //
65         // SONETEMPLATEPAGE METHODS
66         //
67
68         @Override
69         protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
70                 super.processTemplate(request, templateContext);
71                 String query = request.getHttpRequest().getParam("query").trim();
72                 if (query.length() == 0) {
73                         throw new RedirectException("index.html");
74                 }
75
76                 List<Phrase> phrases = parseSearchPhrases(query);
77
78                 Set<Sone> sones = webInterface.getCore().getSones();
79                 Set<Hit<Sone>> soneHits = getHits(sones, phrases, SoneStringGenerator.GENERATOR);
80
81                 Set<Post> posts = new HashSet<Post>();
82                 for (Sone sone : sones) {
83                         posts.addAll(sone.getPosts());
84                 }
85                 @SuppressWarnings("synthetic-access")
86                 Set<Hit<Post>> postHits = getHits(posts, phrases, new PostStringGenerator());
87
88                 /* now filter. */
89                 soneHits = Filters.filteredSet(soneHits, Hit.POSITIVE_FILTER);
90                 postHits = Filters.filteredSet(postHits, Hit.POSITIVE_FILTER);
91
92                 /* now sort. */
93                 List<Hit<Sone>> sortedSoneHits = new ArrayList<Hit<Sone>>(soneHits);
94                 Collections.sort(sortedSoneHits, Hit.DESCENDING_COMPARATOR);
95                 List<Hit<Post>> sortedPostHits = new ArrayList<Hit<Post>>(postHits);
96                 Collections.sort(sortedPostHits, Hit.DESCENDING_COMPARATOR);
97
98                 /* extract Sones and posts. */
99                 List<Sone> resultSones = Converters.convertList(sortedSoneHits, new HitConverter<Sone>());
100                 List<Post> resultPosts = Converters.convertList(sortedPostHits, new HitConverter<Post>());
101
102                 /* pagination. */
103                 Pagination<Sone> sonePagination = new Pagination<Sone>(resultSones, 10).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("sonePage"), 0));
104                 Pagination<Post> postPagination = new Pagination<Post>(resultPosts, 10).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("postPage"), 0));
105
106                 templateContext.set("sonePagination", sonePagination);
107                 templateContext.set("soneHits", sonePagination.getItems());
108                 templateContext.set("postPagination", postPagination);
109                 templateContext.set("postHits", postPagination.getItems());
110         }
111
112         //
113         // PRIVATE METHODS
114         //
115
116         /**
117          * Collects hit information for the given objects. The objects are converted
118          * to a {@link String} using the given {@link StringGenerator}, and the
119          * {@link #calculateScore(List, String) calculated score} is stored together
120          * with the object in a {@link Hit}, and all resulting {@link Hit}s are then
121          * returned.
122          *
123          * @param <T>
124          *            The type of the objects
125          * @param objects
126          *            The objects to search over
127          * @param phrases
128          *            The phrases to search for
129          * @param stringGenerator
130          *            The string generator for the objects
131          * @return The hits for the given phrases
132          */
133         private <T> Set<Hit<T>> getHits(Collection<T> objects, List<Phrase> phrases, StringGenerator<T> stringGenerator) {
134                 Set<Hit<T>> hits = new HashSet<Hit<T>>();
135                 for (T object : objects) {
136                         String objectString = stringGenerator.generateString(object);
137                         int score = calculateScore(phrases, objectString);
138                         hits.add(new Hit<T>(object, score));
139                 }
140                 return hits;
141         }
142
143         /**
144          * Parses the given query into search phrases. The query is split on
145          * whitespace while allowing to group words using single or double quotes.
146          * Isolated phrases starting with a “+” are
147          * {@link Phrase.Optionality#REQUIRED}, phrases with a
148          * “-” are {@link Phrase.Optionality#FORBIDDEN}.
149          *
150          * @param query
151          *            The query to parse
152          * @return The parsed phrases
153          */
154         private List<Phrase> parseSearchPhrases(String query) {
155                 List<String> parsedPhrases = null;
156                 try {
157                         parsedPhrases = StringEscaper.parseLine(query);
158                 } catch (TextException te1) {
159                         /* invalid query. */
160                         return Collections.emptyList();
161                 }
162
163                 List<Phrase> phrases = new ArrayList<Phrase>();
164                 for (String phrase : parsedPhrases) {
165                         if (phrase.startsWith("+")) {
166                                 phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.REQUIRED));
167                         } else if (phrase.startsWith("-")) {
168                                 phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.FORBIDDEN));
169                         }
170                         phrases.add(new Phrase(phrase, Phrase.Optionality.OPTIONAL));
171                 }
172                 return phrases;
173         }
174
175         /**
176          * Calculates the score for the given expression when using the given
177          * phrases.
178          *
179          * @param phrases
180          *            The phrases to search for
181          * @param expression
182          *            The expression to search
183          * @return The score of the expression
184          */
185         private int calculateScore(List<Phrase> phrases, String expression) {
186                 int optionalHits = 0;
187                 int requiredHits = 0;
188                 int forbiddenHits = 0;
189                 int requiredPhrases = 0;
190                 for (Phrase phrase : phrases) {
191                         if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
192                                 ++requiredPhrases;
193                         }
194                         boolean matches = expression.toLowerCase().contains(phrase.getPhrase().toLowerCase());
195                         if (!matches) {
196                                 continue;
197                         }
198                         if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
199                                 ++requiredHits;
200                         }
201                         if (phrase.getOptionality() == Phrase.Optionality.OPTIONAL) {
202                                 ++optionalHits;
203                         }
204                         if (phrase.getOptionality() == Phrase.Optionality.FORBIDDEN) {
205                                 ++forbiddenHits;
206                         }
207                 }
208                 return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2);
209         }
210
211         /**
212          * Converts a given object into a {@link String}.
213          *
214          * @param <T>
215          *            The type of the objects
216          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
217          */
218         private static interface StringGenerator<T> {
219
220                 /**
221                  * Generates a {@link String} for the given object.
222                  *
223                  * @param object
224                  *            The object to generate the {@link String} for
225                  * @return The generated {@link String}
226                  */
227                 public String generateString(T object);
228
229         }
230
231         /**
232          * Generates a {@link String} from a {@link Sone}, concatenating the name of
233          * the Sone and all {@link Profile} {@link Field} values.
234          *
235          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
236          */
237         private static class SoneStringGenerator implements StringGenerator<Sone> {
238
239                 /** A static instance of the Sone string generator. */
240                 public static final SoneStringGenerator GENERATOR = new SoneStringGenerator();
241
242                 /**
243                  * {@inheritDoc}
244                  */
245                 @Override
246                 public String generateString(Sone sone) {
247                         StringBuilder soneString = new StringBuilder();
248                         soneString.append(sone.getName());
249                         Profile soneProfile = sone.getProfile();
250                         if (soneProfile.getFirstName() != null) {
251                                 soneString.append(' ').append(soneProfile.getFirstName());
252                         }
253                         if (soneProfile.getMiddleName() != null) {
254                                 soneString.append(' ').append(soneProfile.getMiddleName());
255                         }
256                         if (soneProfile.getLastName() != null) {
257                                 soneString.append(' ').append(soneProfile.getLastName());
258                         }
259                         for (Field field : soneProfile.getFields()) {
260                                 soneString.append(' ').append(field.getValue());
261                         }
262                         return soneString.toString();
263                 }
264
265         }
266
267         /**
268          * Generates a {@link String} from a {@link Post}, concatenating the text of
269          * the post, the text of all {@link Reply}s, and the name of all
270          * {@link Sone}s that have
271          * replied.
272          *
273          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
274          */
275         private class PostStringGenerator implements StringGenerator<Post> {
276
277                 /**
278                  * {@inheritDoc}
279                  */
280                 @Override
281                 public String generateString(Post post) {
282                         StringBuilder postString = new StringBuilder();
283                         postString.append(post.getText());
284                         if (post.getRecipient() != null) {
285                                 postString.append(' ').append(SoneStringGenerator.GENERATOR.generateString(post.getRecipient()));
286                         }
287                         for (Reply reply : webInterface.getCore().getReplies(post)) {
288                                 postString.append(' ').append(SoneStringGenerator.GENERATOR.generateString(reply.getSone()));
289                                 postString.append(' ').append(reply.getText());
290                         }
291                         return postString.toString();
292                 }
293
294         }
295
296         /**
297          * A search phrase.
298          *
299          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
300          */
301         private static class Phrase {
302
303                 /**
304                  * The optionality of a search phrase.
305                  *
306                  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’
307                  *         Roden</a>
308                  */
309                 public enum Optionality {
310
311                         /** The phrase is optional. */
312                         OPTIONAL,
313
314                         /** The phrase is required. */
315                         REQUIRED,
316
317                         /** The phrase is forbidden. */
318                         FORBIDDEN
319
320                 }
321
322                 /** The phrase to search for. */
323                 private final String phrase;
324
325                 /** The optionality of the phrase. */
326                 private final Optionality optionality;
327
328                 /**
329                  * Creates a new phrase.
330                  *
331                  * @param phrase
332                  *            The phrase to search for
333                  * @param optionality
334                  *            The optionality of the phrase
335                  */
336                 public Phrase(String phrase, Optionality optionality) {
337                         this.optionality = optionality;
338                         this.phrase = phrase;
339                 }
340
341                 /**
342                  * Returns the phrase to search for.
343                  *
344                  * @return The phrase to search for
345                  */
346                 public String getPhrase() {
347                         return phrase;
348                 }
349
350                 /**
351                  * Returns the optionality of the phrase.
352                  *
353                  * @return The optionality of the phrase
354                  */
355                 public Optionality getOptionality() {
356                         return optionality;
357                 }
358
359         }
360
361         /**
362          * A hit consists of a searched object and the score it got for the phrases
363          * of the search.
364          *
365          * @see SearchPage#calculateScore(List, String)
366          * @param <T>
367          *            The type of the searched object
368          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
369          */
370         private static class Hit<T> {
371
372                 /** Filter for {@link Hit}s with a score of more than 0. */
373                 public static final Filter<Hit<?>> POSITIVE_FILTER = new Filter<Hit<?>>() {
374
375                         @Override
376                         public boolean filterObject(Hit<?> hit) {
377                                 return hit.getScore() > 0;
378                         }
379
380                 };
381
382                 /** Comparator that sorts {@link Hit}s descending by score. */
383                 public static final Comparator<Hit<?>> DESCENDING_COMPARATOR = new Comparator<Hit<?>>() {
384
385                         @Override
386                         public int compare(Hit<?> leftHit, Hit<?> rightHit) {
387                                 return rightHit.getScore() - leftHit.getScore();
388                         }
389
390                 };
391
392                 /** The object that was searched. */
393                 private final T object;
394
395                 /** The score of the object. */
396                 private final int score;
397
398                 /**
399                  * Creates a new hit.
400                  *
401                  * @param object
402                  *            The object that was searched
403                  * @param score
404                  *            The score of the object
405                  */
406                 public Hit(T object, int score) {
407                         this.object = object;
408                         this.score = score;
409                 }
410
411                 /**
412                  * Returns the object that was searched.
413                  *
414                  * @return The object that was searched
415                  */
416                 public T getObject() {
417                         return object;
418                 }
419
420                 /**
421                  * Returns the score of the object.
422                  *
423                  * @return The score of the object
424                  */
425                 public int getScore() {
426                         return score;
427                 }
428
429         }
430
431         /**
432          * Extracts the object from a {@link Hit}.
433          *
434          * @param <T>
435          *            The type of the object to extract
436          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
437          */
438         public static class HitConverter<T> implements Converter<Hit<T>, T> {
439
440                 @Override
441                 public T convert(Hit<T> input) {
442                         return input.getObject();
443                 }
444
445         }
446
447 }