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