Redirect to index if no phrases have been found.
[Sone.git] / src / main / java / net / pterodactylus / sone / web / SearchPage.java
1 /*
2  * Sone - SearchPage.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 import java.util.logging.Level;
28 import java.util.logging.Logger;
29
30 import net.pterodactylus.sone.data.Post;
31 import net.pterodactylus.sone.data.Profile;
32 import net.pterodactylus.sone.data.Profile.Field;
33 import net.pterodactylus.sone.data.Reply;
34 import net.pterodactylus.sone.data.Sone;
35 import net.pterodactylus.util.collection.Mapper;
36 import net.pterodactylus.util.collection.Mappers;
37 import net.pterodactylus.util.collection.Pagination;
38 import net.pterodactylus.util.filter.Filter;
39 import net.pterodactylus.util.filter.Filters;
40 import net.pterodactylus.util.logging.Logging;
41 import net.pterodactylus.util.number.Numbers;
42 import net.pterodactylus.util.template.Template;
43 import net.pterodactylus.util.template.TemplateContext;
44 import net.pterodactylus.util.text.StringEscaper;
45 import net.pterodactylus.util.text.TextException;
46
47 /**
48  * This page lets the user search for posts and replies that contain certain
49  * words.
50  *
51  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
52  */
53 public class SearchPage extends SoneTemplatePage {
54
55         /** The logger. */
56         private static final Logger logger = Logging.getLogger(SearchPage.class);
57
58         /**
59          * Creates a new search page.
60          *
61          * @param template
62          *            The template to render
63          * @param webInterface
64          *            The Sone web interface
65          */
66         public SearchPage(Template template, WebInterface webInterface) {
67                 super("search.html", template, "Page.Search.Title", webInterface);
68         }
69
70         //
71         // SONETEMPLATEPAGE METHODS
72         //
73
74         /**
75          * {@inheritDoc}
76          */
77         @Override
78         protected void processTemplate(Request request, TemplateContext templateContext) throws RedirectException {
79                 super.processTemplate(request, templateContext);
80                 String query = request.getHttpRequest().getParam("query").trim();
81                 if (query.length() == 0) {
82                         throw new RedirectException("index.html");
83                 }
84
85                 List<Phrase> phrases = parseSearchPhrases(query);
86                 if (phrases.isEmpty()) {
87                         throw new RedirectException("index.html");
88                 }
89
90                 Set<Sone> sones = webInterface.getCore().getSones();
91                 Set<Hit<Sone>> soneHits = getHits(sones, phrases, SoneStringGenerator.COMPLETE_GENERATOR);
92
93                 Set<Post> posts = new HashSet<Post>();
94                 for (Sone sone : sones) {
95                         posts.addAll(sone.getPosts());
96                 }
97                 @SuppressWarnings("synthetic-access")
98                 Set<Hit<Post>> postHits = getHits(Filters.filteredSet(posts, Post.FUTURE_POSTS_FILTER), phrases, new PostStringGenerator());
99
100                 /* now filter. */
101                 soneHits = Filters.filteredSet(soneHits, Hit.POSITIVE_FILTER);
102                 postHits = Filters.filteredSet(postHits, Hit.POSITIVE_FILTER);
103
104                 /* now sort. */
105                 List<Hit<Sone>> sortedSoneHits = new ArrayList<Hit<Sone>>(soneHits);
106                 Collections.sort(sortedSoneHits, Hit.DESCENDING_COMPARATOR);
107                 List<Hit<Post>> sortedPostHits = new ArrayList<Hit<Post>>(postHits);
108                 Collections.sort(sortedPostHits, Hit.DESCENDING_COMPARATOR);
109
110                 /* extract Sones and posts. */
111                 List<Sone> resultSones = Mappers.mappedList(sortedSoneHits, new HitMapper<Sone>());
112                 List<Post> resultPosts = Mappers.mappedList(sortedPostHits, new HitMapper<Post>());
113
114                 /* pagination. */
115                 Pagination<Sone> sonePagination = new Pagination<Sone>(resultSones, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("sonePage"), 0));
116                 Pagination<Post> postPagination = new Pagination<Post>(resultPosts, webInterface.getCore().getPreferences().getPostsPerPage()).setPage(Numbers.safeParseInteger(request.getHttpRequest().getParam("postPage"), 0));
117
118                 templateContext.set("sonePagination", sonePagination);
119                 templateContext.set("soneHits", sonePagination.getItems());
120                 templateContext.set("postPagination", postPagination);
121                 templateContext.set("postHits", postPagination.getItems());
122         }
123
124         //
125         // PRIVATE METHODS
126         //
127
128         /**
129          * Collects hit information for the given objects. The objects are converted
130          * to a {@link String} using the given {@link StringGenerator}, and the
131          * {@link #calculateScore(List, String) calculated score} is stored together
132          * with the object in a {@link Hit}, and all resulting {@link Hit}s are then
133          * returned.
134          *
135          * @param <T>
136          *            The type of the objects
137          * @param objects
138          *            The objects to search over
139          * @param phrases
140          *            The phrases to search for
141          * @param stringGenerator
142          *            The string generator for the objects
143          * @return The hits for the given phrases
144          */
145         private <T> Set<Hit<T>> getHits(Collection<T> objects, List<Phrase> phrases, StringGenerator<T> stringGenerator) {
146                 Set<Hit<T>> hits = new HashSet<Hit<T>>();
147                 for (T object : objects) {
148                         String objectString = stringGenerator.generateString(object);
149                         double score = calculateScore(phrases, objectString);
150                         hits.add(new Hit<T>(object, score));
151                 }
152                 return hits;
153         }
154
155         /**
156          * Parses the given query into search phrases. The query is split on
157          * whitespace while allowing to group words using single or double quotes.
158          * Isolated phrases starting with a “+” are
159          * {@link Phrase.Optionality#REQUIRED}, phrases with a “-” are
160          * {@link Phrase.Optionality#FORBIDDEN}.
161          *
162          * @param query
163          *            The query to parse
164          * @return The parsed phrases
165          */
166         private List<Phrase> parseSearchPhrases(String query) {
167                 List<String> parsedPhrases = null;
168                 try {
169                         parsedPhrases = StringEscaper.parseLine(query);
170                 } catch (TextException te1) {
171                         /* invalid query. */
172                         return Collections.emptyList();
173                 }
174
175                 List<Phrase> phrases = new ArrayList<Phrase>();
176                 for (String phrase : parsedPhrases) {
177                         if (phrase.startsWith("+")) {
178                                 phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.REQUIRED));
179                         } else if (phrase.startsWith("-")) {
180                                 phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.FORBIDDEN));
181                         }
182                         phrases.add(new Phrase(phrase, Phrase.Optionality.OPTIONAL));
183                 }
184                 return phrases;
185         }
186
187         /**
188          * Calculates the score for the given expression when using the given
189          * phrases.
190          *
191          * @param phrases
192          *            The phrases to search for
193          * @param expression
194          *            The expression to search
195          * @return The score of the expression
196          */
197         private double calculateScore(List<Phrase> phrases, String expression) {
198                 logger.log(Level.FINEST, "Calculating Score for “%s”…", expression);
199                 double optionalHits = 0;
200                 double requiredHits = 0;
201                 int forbiddenHits = 0;
202                 int requiredPhrases = 0;
203                 for (Phrase phrase : phrases) {
204                         String phraseString = phrase.getPhrase().toLowerCase();
205                         if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
206                                 ++requiredPhrases;
207                         }
208                         int matches = 0;
209                         int index = 0;
210                         double score = 0;
211                         while (index < expression.length()) {
212                                 int position = expression.toLowerCase().indexOf(phraseString, index);
213                                 if (position == -1) {
214                                         break;
215                                 }
216                                 score += Math.pow(1 - position / (double) expression.length(), 2);
217                                 index = position + phraseString.length();
218                                 logger.log(Level.FINEST, "Got hit at position %d.", position);
219                                 ++matches;
220                         }
221                         logger.log(Level.FINEST, "Score: %f", score);
222                         if (matches == 0) {
223                                 continue;
224                         }
225                         if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
226                                 requiredHits += score;
227                         }
228                         if (phrase.getOptionality() == Phrase.Optionality.OPTIONAL) {
229                                 optionalHits += score;
230                         }
231                         if (phrase.getOptionality() == Phrase.Optionality.FORBIDDEN) {
232                                 forbiddenHits += matches;
233                         }
234                 }
235                 return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2);
236         }
237
238         /**
239          * Converts a given object into a {@link String}.
240          *
241          * @param <T>
242          *            The type of the objects
243          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
244          */
245         private static interface StringGenerator<T> {
246
247                 /**
248                  * Generates a {@link String} for the given object.
249                  *
250                  * @param object
251                  *            The object to generate the {@link String} for
252                  * @return The generated {@link String}
253                  */
254                 public String generateString(T object);
255
256         }
257
258         /**
259          * Generates a {@link String} from a {@link Sone}, concatenating the name of
260          * the Sone and all {@link Profile} {@link Field} values.
261          *
262          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
263          */
264         private static class SoneStringGenerator implements StringGenerator<Sone> {
265
266                 /** A static instance of a complete Sone string generator. */
267                 public static final SoneStringGenerator COMPLETE_GENERATOR = new SoneStringGenerator(true);
268
269                 /**
270                  * A static instance of a Sone string generator that will only use the
271                  * name of the Sone.
272                  */
273                 public static final SoneStringGenerator NAME_GENERATOR = new SoneStringGenerator(false);
274
275                 /** Whether to generate a string from all data of a Sone. */
276                 private final boolean complete;
277
278                 /**
279                  * Creates a new Sone string generator.
280                  *
281                  * @param complete
282                  *            {@code true} to use the profile’s fields, {@code false} to
283                  *            not to use the profile‘s fields
284                  */
285                 private SoneStringGenerator(boolean complete) {
286                         this.complete = complete;
287                 }
288
289                 /**
290                  * {@inheritDoc}
291                  */
292                 @Override
293                 public String generateString(Sone sone) {
294                         StringBuilder soneString = new StringBuilder();
295                         soneString.append(sone.getName());
296                         Profile soneProfile = sone.getProfile();
297                         if (soneProfile.getFirstName() != null) {
298                                 soneString.append(' ').append(soneProfile.getFirstName());
299                         }
300                         if (soneProfile.getMiddleName() != null) {
301                                 soneString.append(' ').append(soneProfile.getMiddleName());
302                         }
303                         if (soneProfile.getLastName() != null) {
304                                 soneString.append(' ').append(soneProfile.getLastName());
305                         }
306                         if (complete) {
307                                 for (Field field : soneProfile.getFields()) {
308                                         soneString.append(' ').append(field.getValue());
309                                 }
310                         }
311                         return soneString.toString();
312                 }
313
314         }
315
316         /**
317          * Generates a {@link String} from a {@link Post}, concatenating the text of
318          * the post, the text of all {@link Reply}s, and the name of all
319          * {@link Sone}s that have replied.
320          *
321          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
322          */
323         private class PostStringGenerator implements StringGenerator<Post> {
324
325                 /**
326                  * {@inheritDoc}
327                  */
328                 @Override
329                 public String generateString(Post post) {
330                         StringBuilder postString = new StringBuilder();
331                         postString.append(post.getText());
332                         if (post.getRecipient() != null) {
333                                 postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(post.getRecipient()));
334                         }
335                         for (Reply reply : Filters.filteredList(webInterface.getCore().getReplies(post), Reply.FUTURE_REPLIES_FILTER)) {
336                                 postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(reply.getSone()));
337                                 postString.append(' ').append(reply.getText());
338                         }
339                         return postString.toString();
340                 }
341
342         }
343
344         /**
345          * A search phrase.
346          *
347          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
348          */
349         private static class Phrase {
350
351                 /**
352                  * The optionality of a search phrase.
353                  *
354                  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’
355                  *         Roden</a>
356                  */
357                 public enum Optionality {
358
359                         /** The phrase is optional. */
360                         OPTIONAL,
361
362                         /** The phrase is required. */
363                         REQUIRED,
364
365                         /** The phrase is forbidden. */
366                         FORBIDDEN
367
368                 }
369
370                 /** The phrase to search for. */
371                 private final String phrase;
372
373                 /** The optionality of the phrase. */
374                 private final Optionality optionality;
375
376                 /**
377                  * Creates a new phrase.
378                  *
379                  * @param phrase
380                  *            The phrase to search for
381                  * @param optionality
382                  *            The optionality of the phrase
383                  */
384                 public Phrase(String phrase, Optionality optionality) {
385                         this.optionality = optionality;
386                         this.phrase = phrase;
387                 }
388
389                 /**
390                  * Returns the phrase to search for.
391                  *
392                  * @return The phrase to search for
393                  */
394                 public String getPhrase() {
395                         return phrase;
396                 }
397
398                 /**
399                  * Returns the optionality of the phrase.
400                  *
401                  * @return The optionality of the phrase
402                  */
403                 public Optionality getOptionality() {
404                         return optionality;
405                 }
406
407         }
408
409         /**
410          * A hit consists of a searched object and the score it got for the phrases
411          * of the search.
412          *
413          * @see SearchPage#calculateScore(List, String)
414          * @param <T>
415          *            The type of the searched object
416          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
417          */
418         private static class Hit<T> {
419
420                 /** Filter for {@link Hit}s with a score of more than 0. */
421                 public static final Filter<Hit<?>> POSITIVE_FILTER = new Filter<Hit<?>>() {
422
423                         @Override
424                         public boolean filterObject(Hit<?> hit) {
425                                 return hit.getScore() > 0;
426                         }
427
428                 };
429
430                 /** Comparator that sorts {@link Hit}s descending by score. */
431                 public static final Comparator<Hit<?>> DESCENDING_COMPARATOR = new Comparator<Hit<?>>() {
432
433                         @Override
434                         public int compare(Hit<?> leftHit, Hit<?> rightHit) {
435                                 return (rightHit.getScore() < leftHit.getScore()) ? -1 : ((rightHit.getScore() > leftHit.getScore()) ? 1 : 0);
436                         }
437
438                 };
439
440                 /** The object that was searched. */
441                 private final T object;
442
443                 /** The score of the object. */
444                 private final double score;
445
446                 /**
447                  * Creates a new hit.
448                  *
449                  * @param object
450                  *            The object that was searched
451                  * @param score
452                  *            The score of the object
453                  */
454                 public Hit(T object, double score) {
455                         this.object = object;
456                         this.score = score;
457                 }
458
459                 /**
460                  * Returns the object that was searched.
461                  *
462                  * @return The object that was searched
463                  */
464                 public T getObject() {
465                         return object;
466                 }
467
468                 /**
469                  * Returns the score of the object.
470                  *
471                  * @return The score of the object
472                  */
473                 public double getScore() {
474                         return score;
475                 }
476
477         }
478
479         /**
480          * Extracts the object from a {@link Hit}.
481          *
482          * @param <T>
483          *            The type of the object to extract
484          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
485          */
486         public static class HitMapper<T> implements Mapper<Hit<T>, T> {
487
488                 /**
489                  * {@inheritDoc}
490                  */
491                 @Override
492                 public T map(Hit<T> input) {
493                         return input.getObject();
494                 }
495
496         }
497
498 }