Merge branch 'release-0.6.1'
[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.COMPLETE_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(Filters.filteredSet(posts, Post.FUTURE_POSTS_FILTER), 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, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("sonePage"), 0));
107                 Pagination<Post> postPagination = new Pagination<Post>(resultPosts, webInterface.getCore().getPreferences().getPostsPerPage()).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                         String phraseString = phrase.getPhrase().toLowerCase();
195                         if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
196                                 ++requiredPhrases;
197                         }
198                         int matches = 0;
199                         int index = 0;
200                         while (index < expression.length()) {
201                                 int position = expression.toLowerCase().indexOf(phraseString, index);
202                                 if (position == -1) {
203                                         break;
204                                 }
205                                 index = position + phraseString.length();
206                                 ++matches;
207                         }
208                         if (matches == 0) {
209                                 continue;
210                         }
211                         if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
212                                 requiredHits += matches;
213                         }
214                         if (phrase.getOptionality() == Phrase.Optionality.OPTIONAL) {
215                                 optionalHits += matches;
216                         }
217                         if (phrase.getOptionality() == Phrase.Optionality.FORBIDDEN) {
218                                 forbiddenHits += matches;
219                         }
220                 }
221                 return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2);
222         }
223
224         /**
225          * Converts a given object into a {@link String}.
226          *
227          * @param <T>
228          *            The type of the objects
229          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
230          */
231         private static interface StringGenerator<T> {
232
233                 /**
234                  * Generates a {@link String} for the given object.
235                  *
236                  * @param object
237                  *            The object to generate the {@link String} for
238                  * @return The generated {@link String}
239                  */
240                 public String generateString(T object);
241
242         }
243
244         /**
245          * Generates a {@link String} from a {@link Sone}, concatenating the name of
246          * the Sone and all {@link Profile} {@link Field} values.
247          *
248          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
249          */
250         private static class SoneStringGenerator implements StringGenerator<Sone> {
251
252                 /** A static instance of a complete Sone string generator. */
253                 public static final SoneStringGenerator COMPLETE_GENERATOR = new SoneStringGenerator(true);
254
255                 /**
256                  * A static instance of a Sone string generator that will only use the
257                  * name of the Sone.
258                  */
259                 public static final SoneStringGenerator NAME_GENERATOR = new SoneStringGenerator(false);
260
261                 /** Whether to generate a string from all data of a Sone. */
262                 private final boolean complete;
263
264                 /**
265                  * Creates a new Sone string generator.
266                  *
267                  * @param complete
268                  *            {@code true} to use the profile’s fields, {@code false} to
269                  *            not to use the profile‘s fields
270                  */
271                 private SoneStringGenerator(boolean complete) {
272                         this.complete = complete;
273                 }
274
275                 /**
276                  * {@inheritDoc}
277                  */
278                 @Override
279                 public String generateString(Sone sone) {
280                         StringBuilder soneString = new StringBuilder();
281                         soneString.append(sone.getName());
282                         Profile soneProfile = sone.getProfile();
283                         if (soneProfile.getFirstName() != null) {
284                                 soneString.append(' ').append(soneProfile.getFirstName());
285                         }
286                         if (soneProfile.getMiddleName() != null) {
287                                 soneString.append(' ').append(soneProfile.getMiddleName());
288                         }
289                         if (soneProfile.getLastName() != null) {
290                                 soneString.append(' ').append(soneProfile.getLastName());
291                         }
292                         if (complete) {
293                                 for (Field field : soneProfile.getFields()) {
294                                         soneString.append(' ').append(field.getValue());
295                                 }
296                         }
297                         return soneString.toString();
298                 }
299
300         }
301
302         /**
303          * Generates a {@link String} from a {@link Post}, concatenating the text of
304          * the post, the text of all {@link Reply}s, and the name of all
305          * {@link Sone}s that have replied.
306          *
307          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
308          */
309         private class PostStringGenerator implements StringGenerator<Post> {
310
311                 /**
312                  * {@inheritDoc}
313                  */
314                 @Override
315                 public String generateString(Post post) {
316                         StringBuilder postString = new StringBuilder();
317                         postString.append(post.getText());
318                         if (post.getRecipient() != null) {
319                                 postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(post.getRecipient()));
320                         }
321                         for (Reply reply : Filters.filteredList(webInterface.getCore().getReplies(post), Reply.FUTURE_REPLIES_FILTER)) {
322                                 postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(reply.getSone()));
323                                 postString.append(' ').append(reply.getText());
324                         }
325                         return postString.toString();
326                 }
327
328         }
329
330         /**
331          * A search phrase.
332          *
333          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
334          */
335         private static class Phrase {
336
337                 /**
338                  * The optionality of a search phrase.
339                  *
340                  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’
341                  *         Roden</a>
342                  */
343                 public enum Optionality {
344
345                         /** The phrase is optional. */
346                         OPTIONAL,
347
348                         /** The phrase is required. */
349                         REQUIRED,
350
351                         /** The phrase is forbidden. */
352                         FORBIDDEN
353
354                 }
355
356                 /** The phrase to search for. */
357                 private final String phrase;
358
359                 /** The optionality of the phrase. */
360                 private final Optionality optionality;
361
362                 /**
363                  * Creates a new phrase.
364                  *
365                  * @param phrase
366                  *            The phrase to search for
367                  * @param optionality
368                  *            The optionality of the phrase
369                  */
370                 public Phrase(String phrase, Optionality optionality) {
371                         this.optionality = optionality;
372                         this.phrase = phrase;
373                 }
374
375                 /**
376                  * Returns the phrase to search for.
377                  *
378                  * @return The phrase to search for
379                  */
380                 public String getPhrase() {
381                         return phrase;
382                 }
383
384                 /**
385                  * Returns the optionality of the phrase.
386                  *
387                  * @return The optionality of the phrase
388                  */
389                 public Optionality getOptionality() {
390                         return optionality;
391                 }
392
393         }
394
395         /**
396          * A hit consists of a searched object and the score it got for the phrases
397          * of the search.
398          *
399          * @see SearchPage#calculateScore(List, String)
400          * @param <T>
401          *            The type of the searched object
402          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
403          */
404         private static class Hit<T> {
405
406                 /** Filter for {@link Hit}s with a score of more than 0. */
407                 public static final Filter<Hit<?>> POSITIVE_FILTER = new Filter<Hit<?>>() {
408
409                         @Override
410                         public boolean filterObject(Hit<?> hit) {
411                                 return hit.getScore() > 0;
412                         }
413
414                 };
415
416                 /** Comparator that sorts {@link Hit}s descending by score. */
417                 public static final Comparator<Hit<?>> DESCENDING_COMPARATOR = new Comparator<Hit<?>>() {
418
419                         @Override
420                         public int compare(Hit<?> leftHit, Hit<?> rightHit) {
421                                 return rightHit.getScore() - leftHit.getScore();
422                         }
423
424                 };
425
426                 /** The object that was searched. */
427                 private final T object;
428
429                 /** The score of the object. */
430                 private final int score;
431
432                 /**
433                  * Creates a new hit.
434                  *
435                  * @param object
436                  *            The object that was searched
437                  * @param score
438                  *            The score of the object
439                  */
440                 public Hit(T object, int score) {
441                         this.object = object;
442                         this.score = score;
443                 }
444
445                 /**
446                  * Returns the object that was searched.
447                  *
448                  * @return The object that was searched
449                  */
450                 public T getObject() {
451                         return object;
452                 }
453
454                 /**
455                  * Returns the score of the object.
456                  *
457                  * @return The score of the object
458                  */
459                 public int getScore() {
460                         return score;
461                 }
462
463         }
464
465         /**
466          * Extracts the object from a {@link Hit}.
467          *
468          * @param <T>
469          *            The type of the object to extract
470          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
471          */
472         public static class HitConverter<T> implements Converter<Hit<T>, T> {
473
474                 /**
475                  * {@inheritDoc}
476                  */
477                 @Override
478                 public T convert(Hit<T> input) {
479                         return input.getObject();
480                 }
481
482         }
483
484 }