1 package net.pterodactylus.sone.web.pages
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.paginate
13 import net.pterodactylus.sone.utils.parameters
14 import net.pterodactylus.sone.web.WebInterface
15 import net.pterodactylus.sone.web.page.FreenetRequest
16 import net.pterodactylus.sone.web.pages.SearchPage.Optionality.FORBIDDEN
17 import net.pterodactylus.sone.web.pages.SearchPage.Optionality.OPTIONAL
18 import net.pterodactylus.sone.web.pages.SearchPage.Optionality.REQUIRED
19 import net.pterodactylus.util.template.Template
20 import net.pterodactylus.util.template.TemplateContext
21 import net.pterodactylus.util.text.StringEscaper
22 import net.pterodactylus.util.text.TextException
23 import java.util.concurrent.TimeUnit.MINUTES
26 * This page lets the user search for posts and replies that contain certain
29 class SearchPage @JvmOverloads constructor(template: Template, webInterface: WebInterface, ticker: Ticker = Ticker.systemTicker()):
30 SoneTemplatePage("search.html", template, "Page.Search.Title", webInterface, false) {
32 private val cache: Cache<Iterable<Phrase>, Pagination<Post>> = CacheBuilder.newBuilder().ticker(ticker).expireAfterAccess(5, MINUTES).build()
34 override fun handleRequest(freenetRequest: FreenetRequest, templateContext: TemplateContext) {
35 val startTime = System.currentTimeMillis()
37 freenetRequest.parameters["query"].emptyToNull?.parse()
38 } catch (te: TextException) {
39 redirect("index.html")
41 ?: redirect("index.html")
44 0 -> redirect("index.html")
45 1 -> phrases.first().phrase.also { word ->
47 word.removePrefix("sone://").let(webInterface.core::getSone) != null -> redirect("viewSone.html?sone=${word.removePrefix("sone://")}")
48 word.removePrefix("post://").let(webInterface.core::getPost) != null -> redirect("viewPost.html?post=${word.removePrefix("post://")}")
49 word.removePrefix("reply://").let(webInterface.core::getPostReply) != null -> redirect("viewPost.html?post=${word.removePrefix("reply://").let(webInterface.core::getPostReply)?.postId}")
50 word.removePrefix("album://").let(webInterface.core::getAlbum) != null -> redirect("imageBrowser.html?album=${word.removePrefix("album://")}")
51 word.removePrefix("image://").let { webInterface.core.getImage(it, false) } != null -> redirect("imageBrowser.html?image=${word.removePrefix("image://")}")
56 val sonePagination = webInterface.core.sones
57 .scoreAndPaginate(phrases) { it.allText() }
58 .apply { page = freenetRequest.parameters["sonePage"].emptyToNull?.toIntOrNull() ?: 0 }
59 val postPagination = cache.get(phrases) {
60 webInterface.core.sones
61 .flatMap(Sone::getPosts)
62 .filter { Post.FUTURE_POSTS_FILTER.apply(it) }
63 .scoreAndPaginate(phrases) { it.allText() }
64 }.apply { page = freenetRequest.parameters["postPage"].emptyToNull?.toIntOrNull() ?: 0 }
66 Logger.normal(SearchPage::class.java, "Finished search for “${freenetRequest.parameters["query"]}” in ${System.currentTimeMillis() - startTime}ms.")
67 templateContext["sonePagination"] = sonePagination
68 templateContext["soneHits"] = sonePagination.items
69 templateContext["postPagination"] = postPagination
70 templateContext["postHits"] = postPagination.items
73 private fun <T> Iterable<T>.scoreAndPaginate(phrases: Iterable<Phrase>, texter: (T) -> String) =
74 map { it to score(texter(it), phrases) }
75 .filter { it.second > 0 }
76 .sortedByDescending { it.second }
78 .paginate(webInterface.core.preferences.postsPerPage)
80 private fun Sone.names() =
81 listOf(name, profile.firstName, profile.middleName, profile.lastName)
85 private fun Sone.allText() =
86 (names() + profile.fields.map { "${it.name} ${it.value}" }.joinToString(" ", " ")).toLowerCase()
88 private fun Post.allText() =
89 (text + recipient.orNull()?.let { " ${it.names()}" } + webInterface.core.getReplies(id)
90 .filter { PostReply.FUTURE_REPLY_FILTER.apply(it) }
91 .map { "${it.sone.names()} ${it.text}" }.joinToString(" ", " ")).toLowerCase()
93 private fun score(text: String, phrases: Iterable<Phrase>): Double {
94 val requiredPhrases = phrases.count { it.required }
95 val requiredHits = phrases.filter(Phrase::required)
97 .flatMap { text.findAll(it) }
98 .map { Math.pow(1 - it / text.length.toDouble(), 2.0) }
100 val optionalHits = phrases.filter(Phrase::optional)
102 .flatMap { text.findAll(it) }
103 .map { Math.pow(1 - it / text.length.toDouble(), 2.0) }
105 val forbiddenHits = phrases.filter(Phrase::forbidden)
107 .map { text.findAll(it).size }
109 return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2)
112 private fun String.findAll(needle: String) =
113 generateSequence(indexOf(needle).takeIf { it > -1 }) { lastPosition ->
115 .let { indexOf(needle, it + 1) }
119 private fun String.parse() =
120 StringEscaper.parseLine(this)
121 .map(String::toLowerCase)
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)
131 private fun redirect(target: String): Nothing = throw RedirectException(target)
133 enum class Optionality {
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