✨ Use @TemplatePath annotations on most pages
[Sone.git] / src / main / kotlin / net / pterodactylus / sone / web / pages / SearchPage.kt
1 package net.pterodactylus.sone.web.pages
2
3 import com.google.common.base.Ticker
4 import com.google.common.cache.Cache
5 import com.google.common.cache.CacheBuilder
6 import freenet.support.Logger
7 import net.pterodactylus.sone.data.Post
8 import net.pterodactylus.sone.data.PostReply
9 import net.pterodactylus.sone.data.Sone
10 import net.pterodactylus.sone.main.*
11 import net.pterodactylus.sone.utils.Pagination
12 import net.pterodactylus.sone.utils.emptyToNull
13 import net.pterodactylus.sone.utils.memoize
14 import net.pterodactylus.sone.utils.paginate
15 import net.pterodactylus.sone.utils.parameters
16 import net.pterodactylus.sone.web.WebInterface
17 import net.pterodactylus.sone.web.page.*
18 import net.pterodactylus.sone.web.pages.SearchPage.Optionality.FORBIDDEN
19 import net.pterodactylus.sone.web.pages.SearchPage.Optionality.OPTIONAL
20 import net.pterodactylus.sone.web.pages.SearchPage.Optionality.REQUIRED
21 import net.pterodactylus.util.template.Template
22 import net.pterodactylus.util.template.TemplateContext
23 import net.pterodactylus.util.text.StringEscaper
24 import net.pterodactylus.util.text.TextException
25 import java.util.concurrent.TimeUnit.MINUTES
26 import javax.inject.Inject
27
28 /**
29  * This page lets the user search for posts and replies that contain certain
30  * words.
31  */
32 @TemplatePath("/templates/search.html")
33 class SearchPage(template: Template, webInterface: WebInterface, loaders: Loaders, templateRenderer: TemplateRenderer, ticker: Ticker = Ticker.systemTicker()) :
34                 SoneTemplatePage("search.html", webInterface, loaders, template, templateRenderer, pageTitleKey = "Page.Search.Title") {
35
36         @Inject constructor(template: Template, webInterface: WebInterface, loaders: Loaders, templateRenderer: TemplateRenderer) :
37                         this(template, webInterface, loaders, templateRenderer, Ticker.systemTicker())
38
39         private val cache: Cache<Iterable<Phrase>, Pagination<Post>> = CacheBuilder.newBuilder().ticker(ticker).expireAfterAccess(5, MINUTES).build()
40
41         override fun handleRequest(soneRequest: SoneRequest, templateContext: TemplateContext) {
42                 val startTime = System.currentTimeMillis()
43                 val phrases = try {
44                         soneRequest.parameters["query"].emptyToNull?.parse()
45                 } catch (te: TextException) {
46                         redirect("index.html")
47                 }
48                                 ?: redirect("index.html")
49
50                 when (phrases.size) {
51                         0 -> redirect("index.html")
52                         1 -> phrases.first().phrase.also { word ->
53                                 when {
54                                         word.removePrefix("sone://").let(soneRequest.core::getSone) != null -> redirect("viewSone.html?sone=${word.removePrefix("sone://")}")
55                                         word.removePrefix("post://").let(soneRequest.core::getPost) != null -> redirect("viewPost.html?post=${word.removePrefix("post://")}")
56                                         word.removePrefix("reply://").let(soneRequest.core::getPostReply) != null -> redirect("viewPost.html?post=${word.removePrefix("reply://").let(soneRequest.core::getPostReply)?.postId}")
57                                         word.removePrefix("album://").let(soneRequest.core::getAlbum) != null -> redirect("imageBrowser.html?album=${word.removePrefix("album://")}")
58                                         word.removePrefix("image://").let { soneRequest.core.getImage(it, false) } != null -> redirect("imageBrowser.html?image=${word.removePrefix("image://")}")
59                                 }
60                         }
61                 }
62
63                 val soneNameCache = { sone: Sone -> sone.names() }.memoize()
64                 val sonePagination = soneRequest.core.sones
65                                 .scoreAndPaginate(phrases, soneRequest.core.preferences.postsPerPage) { it.allText(soneNameCache) }
66                                 .apply { page = soneRequest.parameters["sonePage"].emptyToNull?.toIntOrNull() ?: 0 }
67                 val postPagination = cache.get(phrases) {
68                         soneRequest.core.sones
69                                         .flatMap(Sone::getPosts)
70                                         .filter { Post.FUTURE_POSTS_FILTER.apply(it) }
71                                         .scoreAndPaginate(phrases, soneRequest.core.preferences.postsPerPage) { it.allText(soneNameCache, soneRequest.core::getReplies) }
72                 }.apply { page = soneRequest.parameters["postPage"].emptyToNull?.toIntOrNull() ?: 0 }
73
74                 Logger.normal(SearchPage::class.java, "Finished search for “${soneRequest.parameters["query"]}” in ${System.currentTimeMillis() - startTime}ms.")
75                 templateContext["sonePagination"] = sonePagination
76                 templateContext["soneHits"] = sonePagination.items
77                 templateContext["postPagination"] = postPagination
78                 templateContext["postHits"] = postPagination.items
79         }
80
81         private fun <T> Iterable<T>.scoreAndPaginate(phrases: Iterable<Phrase>, postsPerPage: Int, texter: (T) -> String) =
82                         map { it to score(texter(it), phrases) }
83                                         .filter { it.second > 0 }
84                                         .sortedByDescending { it.second }
85                                         .map { it.first }
86                                         .paginate(postsPerPage)
87
88         private fun Sone.names() =
89                         with(profile) {
90                                 listOf(name, firstName, middleName, lastName)
91                                                 .filterNotNull()
92                                                 .joinToString("")
93                         }
94
95         private fun Sone.allText(soneNameCache: (Sone) -> String) =
96                         (soneNameCache(this) + profile.fields.map { "${it.name} ${it.value}" }.joinToString(" ", " ")).toLowerCase()
97
98         private fun Post.allText(soneNameCache: (Sone) -> String, getReplies: (String) -> Collection<PostReply>) =
99                         (text + recipient.orNull()?.let { " ${soneNameCache(it)}" } + getReplies(id)
100                                         .filter { PostReply.FUTURE_REPLY_FILTER.apply(it) }
101                                         .map { "${soneNameCache(it.sone)} ${it.text}" }.joinToString(" ", " ")).toLowerCase()
102
103         private fun Iterable<Phrase>.indicesFor(text: String, predicate: (Phrase) -> Boolean) =
104                         filter(predicate).map(Phrase::phrase).map(String::toLowerCase).flatMap { text.findAll(it) }
105
106         private fun score(text: String, phrases: Iterable<Phrase>): Double {
107                 val requiredPhrases = phrases.count { it.required }
108                 val requiredHits = phrases.indicesFor(text, Phrase::required)
109                                 .map { Math.pow(1 - it / text.length.toDouble(), 2.0) }
110                                 .sum()
111                 val optionalHits = phrases.indicesFor(text, Phrase::optional)
112                                 .map { Math.pow(1 - it / text.length.toDouble(), 2.0) }
113                                 .sum()
114                 val forbiddenHits = phrases.indicesFor(text, Phrase::forbidden)
115                                 .count()
116                 return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2)
117         }
118
119         private fun String.findAll(needle: String) =
120                         generateSequence(indexOf(needle).takeIf { it > -1 }) { lastPosition ->
121                                 lastPosition
122                                                 .let { indexOf(needle, it + 1) }
123                                                 .takeIf { it > -1 }
124                         }.toList()
125
126         private fun String.parse() =
127                         StringEscaper.parseLine(this)
128                                         .map {
129                                                 when {
130                                                         it == "+" || it == "-" -> Phrase(it, OPTIONAL)
131                                                         it.startsWith("+") -> Phrase(it.drop(1), REQUIRED)
132                                                         it.startsWith("-") -> Phrase(it.drop(1), FORBIDDEN)
133                                                         else -> Phrase(it, OPTIONAL)
134                                                 }
135                                         }
136
137         private fun redirect(target: String): Nothing = throw RedirectException(target)
138
139         enum class Optionality {
140                 OPTIONAL,
141                 REQUIRED,
142                 FORBIDDEN
143         }
144
145         private data class Phrase(val phrase: String, val optionality: Optionality) {
146                 val required = optionality == REQUIRED
147                 val forbidden = optionality == FORBIDDEN
148                 val optional = optionality == OPTIONAL
149         }
150
151 }