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