Override hashCode() and equals() in Phrase.
[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                                 if (phrase.length() > 1) {
179                                         phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.REQUIRED));
180                                 } else {
181                                         phrases.add(new Phrase("+", Phrase.Optionality.OPTIONAL));
182                                 }
183                         } else if (phrase.startsWith("-")) {
184                                 if (phrase.length() > 1) {
185                                         phrases.add(new Phrase(phrase.substring(1), Phrase.Optionality.FORBIDDEN));
186                                 } else {
187                                         phrases.add(new Phrase("-", Phrase.Optionality.OPTIONAL));
188                                 }
189                         } else {
190                                 phrases.add(new Phrase(phrase, Phrase.Optionality.OPTIONAL));
191                         }
192                 }
193                 return phrases;
194         }
195
196         /**
197          * Calculates the score for the given expression when using the given
198          * phrases.
199          *
200          * @param phrases
201          *            The phrases to search for
202          * @param expression
203          *            The expression to search
204          * @return The score of the expression
205          */
206         private double calculateScore(List<Phrase> phrases, String expression) {
207                 logger.log(Level.FINEST, "Calculating Score for “%s”…", expression);
208                 double optionalHits = 0;
209                 double requiredHits = 0;
210                 int forbiddenHits = 0;
211                 int requiredPhrases = 0;
212                 for (Phrase phrase : phrases) {
213                         String phraseString = phrase.getPhrase().toLowerCase();
214                         if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
215                                 ++requiredPhrases;
216                         }
217                         int matches = 0;
218                         int index = 0;
219                         double score = 0;
220                         while (index < expression.length()) {
221                                 int position = expression.toLowerCase().indexOf(phraseString, index);
222                                 if (position == -1) {
223                                         break;
224                                 }
225                                 score += Math.pow(1 - position / (double) expression.length(), 2);
226                                 index = position + phraseString.length();
227                                 logger.log(Level.FINEST, "Got hit at position %d.", position);
228                                 ++matches;
229                         }
230                         logger.log(Level.FINEST, "Score: %f", score);
231                         if (matches == 0) {
232                                 continue;
233                         }
234                         if (phrase.getOptionality() == Phrase.Optionality.REQUIRED) {
235                                 requiredHits += score;
236                         }
237                         if (phrase.getOptionality() == Phrase.Optionality.OPTIONAL) {
238                                 optionalHits += score;
239                         }
240                         if (phrase.getOptionality() == Phrase.Optionality.FORBIDDEN) {
241                                 forbiddenHits += matches;
242                         }
243                 }
244                 return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2);
245         }
246
247         /**
248          * Converts a given object into a {@link String}.
249          *
250          * @param <T>
251          *            The type of the objects
252          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
253          */
254         private static interface StringGenerator<T> {
255
256                 /**
257                  * Generates a {@link String} for the given object.
258                  *
259                  * @param object
260                  *            The object to generate the {@link String} for
261                  * @return The generated {@link String}
262                  */
263                 public String generateString(T object);
264
265         }
266
267         /**
268          * Generates a {@link String} from a {@link Sone}, concatenating the name of
269          * the Sone and all {@link Profile} {@link Field} values.
270          *
271          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
272          */
273         private static class SoneStringGenerator implements StringGenerator<Sone> {
274
275                 /** A static instance of a complete Sone string generator. */
276                 public static final SoneStringGenerator COMPLETE_GENERATOR = new SoneStringGenerator(true);
277
278                 /**
279                  * A static instance of a Sone string generator that will only use the
280                  * name of the Sone.
281                  */
282                 public static final SoneStringGenerator NAME_GENERATOR = new SoneStringGenerator(false);
283
284                 /** Whether to generate a string from all data of a Sone. */
285                 private final boolean complete;
286
287                 /**
288                  * Creates a new Sone string generator.
289                  *
290                  * @param complete
291                  *            {@code true} to use the profile’s fields, {@code false} to
292                  *            not to use the profile‘s fields
293                  */
294                 private SoneStringGenerator(boolean complete) {
295                         this.complete = complete;
296                 }
297
298                 /**
299                  * {@inheritDoc}
300                  */
301                 @Override
302                 public String generateString(Sone sone) {
303                         StringBuilder soneString = new StringBuilder();
304                         soneString.append(sone.getName());
305                         Profile soneProfile = sone.getProfile();
306                         if (soneProfile.getFirstName() != null) {
307                                 soneString.append(' ').append(soneProfile.getFirstName());
308                         }
309                         if (soneProfile.getMiddleName() != null) {
310                                 soneString.append(' ').append(soneProfile.getMiddleName());
311                         }
312                         if (soneProfile.getLastName() != null) {
313                                 soneString.append(' ').append(soneProfile.getLastName());
314                         }
315                         if (complete) {
316                                 for (Field field : soneProfile.getFields()) {
317                                         soneString.append(' ').append(field.getValue());
318                                 }
319                         }
320                         return soneString.toString();
321                 }
322
323         }
324
325         /**
326          * Generates a {@link String} from a {@link Post}, concatenating the text of
327          * the post, the text of all {@link Reply}s, and the name of all
328          * {@link Sone}s that have replied.
329          *
330          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
331          */
332         private class PostStringGenerator implements StringGenerator<Post> {
333
334                 /**
335                  * {@inheritDoc}
336                  */
337                 @Override
338                 public String generateString(Post post) {
339                         StringBuilder postString = new StringBuilder();
340                         postString.append(post.getText());
341                         if (post.getRecipient() != null) {
342                                 postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(post.getRecipient()));
343                         }
344                         for (Reply reply : Filters.filteredList(webInterface.getCore().getReplies(post), Reply.FUTURE_REPLIES_FILTER)) {
345                                 postString.append(' ').append(SoneStringGenerator.NAME_GENERATOR.generateString(reply.getSone()));
346                                 postString.append(' ').append(reply.getText());
347                         }
348                         return postString.toString();
349                 }
350
351         }
352
353         /**
354          * A search phrase.
355          *
356          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
357          */
358         private static class Phrase {
359
360                 /**
361                  * The optionality of a search phrase.
362                  *
363                  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’
364                  *         Roden</a>
365                  */
366                 public enum Optionality {
367
368                         /** The phrase is optional. */
369                         OPTIONAL,
370
371                         /** The phrase is required. */
372                         REQUIRED,
373
374                         /** The phrase is forbidden. */
375                         FORBIDDEN
376
377                 }
378
379                 /** The phrase to search for. */
380                 private final String phrase;
381
382                 /** The optionality of the phrase. */
383                 private final Optionality optionality;
384
385                 /**
386                  * Creates a new phrase.
387                  *
388                  * @param phrase
389                  *            The phrase to search for
390                  * @param optionality
391                  *            The optionality of the phrase
392                  */
393                 public Phrase(String phrase, Optionality optionality) {
394                         this.optionality = optionality;
395                         this.phrase = phrase;
396                 }
397
398                 /**
399                  * Returns the phrase to search for.
400                  *
401                  * @return The phrase to search for
402                  */
403                 public String getPhrase() {
404                         return phrase;
405                 }
406
407                 /**
408                  * Returns the optionality of the phrase.
409                  *
410                  * @return The optionality of the phrase
411                  */
412                 public Optionality getOptionality() {
413                         return optionality;
414                 }
415
416                 //
417                 // OBJECT METHODS
418                 //
419
420                 /**
421                  * {@inheritDoc}
422                  */
423                 @Override
424                 public int hashCode() {
425                         return phrase.hashCode() ^ ((optionality == Optionality.FORBIDDEN) ? (0xaaaaaaaa) : ((optionality == Optionality.REQUIRED) ? 0x55555555 : 0));
426                 }
427
428                 /**
429                  * {@inheritDoc}
430                  */
431                 @Override
432                 public boolean equals(Object object) {
433                         if (!(object instanceof Phrase)) {
434                                 return false;
435                         }
436                         Phrase phrase = (Phrase) object;
437                         return (this.optionality == phrase.optionality) && this.phrase.equals(phrase.phrase);
438                 }
439
440         }
441
442         /**
443          * A hit consists of a searched object and the score it got for the phrases
444          * of the search.
445          *
446          * @see SearchPage#calculateScore(List, String)
447          * @param <T>
448          *            The type of the searched object
449          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
450          */
451         private static class Hit<T> {
452
453                 /** Filter for {@link Hit}s with a score of more than 0. */
454                 public static final Filter<Hit<?>> POSITIVE_FILTER = new Filter<Hit<?>>() {
455
456                         @Override
457                         public boolean filterObject(Hit<?> hit) {
458                                 return hit.getScore() > 0;
459                         }
460
461                 };
462
463                 /** Comparator that sorts {@link Hit}s descending by score. */
464                 public static final Comparator<Hit<?>> DESCENDING_COMPARATOR = new Comparator<Hit<?>>() {
465
466                         @Override
467                         public int compare(Hit<?> leftHit, Hit<?> rightHit) {
468                                 return (rightHit.getScore() < leftHit.getScore()) ? -1 : ((rightHit.getScore() > leftHit.getScore()) ? 1 : 0);
469                         }
470
471                 };
472
473                 /** The object that was searched. */
474                 private final T object;
475
476                 /** The score of the object. */
477                 private final double score;
478
479                 /**
480                  * Creates a new hit.
481                  *
482                  * @param object
483                  *            The object that was searched
484                  * @param score
485                  *            The score of the object
486                  */
487                 public Hit(T object, double score) {
488                         this.object = object;
489                         this.score = score;
490                 }
491
492                 /**
493                  * Returns the object that was searched.
494                  *
495                  * @return The object that was searched
496                  */
497                 public T getObject() {
498                         return object;
499                 }
500
501                 /**
502                  * Returns the score of the object.
503                  *
504                  * @return The score of the object
505                  */
506                 public double getScore() {
507                         return score;
508                 }
509
510         }
511
512         /**
513          * Extracts the object from a {@link Hit}.
514          *
515          * @param <T>
516          *            The type of the object to extract
517          * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
518          */
519         public static class HitMapper<T> implements Mapper<Hit<T>, T> {
520
521                 /**
522                  * {@inheritDoc}
523                  */
524                 @Override
525                 public T map(Hit<T> input) {
526                         return input.getObject();
527                 }
528
529         }
530
531 }