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