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