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