1 package net.pterodactylus.sone.web.pages
3 import com.google.common.base.Ticker
4 import com.google.common.cache.*
5 import freenet.support.*
6 import net.pterodactylus.sone.data.*
7 import net.pterodactylus.sone.main.*
8 import net.pterodactylus.sone.utils.*
9 import net.pterodactylus.sone.web.*
10 import net.pterodactylus.sone.web.page.*
11 import net.pterodactylus.sone.web.pages.SearchPage.Optionality.*
12 import net.pterodactylus.util.template.*
13 import net.pterodactylus.util.text.*
14 import java.util.concurrent.TimeUnit.*
18 * This page lets the user search for posts and replies that contain certain
21 @TemplatePath("/templates/search.html")
22 @ToadletPath("search.html")
23 class SearchPage(webInterface: WebInterface, loaders: Loaders, templateRenderer: TemplateRenderer, ticker: Ticker = Ticker.systemTicker()) :
24 SoneTemplatePage(webInterface, loaders, templateRenderer, pageTitleKey = "Page.Search.Title") {
27 constructor(webInterface: WebInterface, loaders: Loaders, templateRenderer: TemplateRenderer) :
28 this(webInterface, loaders, templateRenderer, Ticker.systemTicker())
30 private val cache: Cache<Iterable<Phrase>, Pagination<Post>> = CacheBuilder.newBuilder().ticker(ticker).expireAfterAccess(5, MINUTES).build()
32 override fun handleRequest(soneRequest: SoneRequest, templateContext: TemplateContext) {
33 val startTime = System.currentTimeMillis()
35 soneRequest.parameters["query"].emptyToNull?.parse()
36 } catch (te: TextException) {
37 redirect("index.html")
39 ?: redirect("index.html")
42 0 -> redirect("index.html")
43 1 -> phrases.first().phrase.also { word ->
45 word.removePrefix("sone://").let(soneRequest.core::getSone) != null -> redirect("viewSone.html?sone=${word.removePrefix("sone://")}")
46 word.removePrefix("post://").let(soneRequest.core::getPost) != null -> redirect("viewPost.html?post=${word.removePrefix("post://")}")
47 word.removePrefix("reply://").let(soneRequest.core::getPostReply) != null -> redirect("viewPost.html?post=${word.removePrefix("reply://").let(soneRequest.core::getPostReply)?.postId}")
48 word.removePrefix("album://").let(soneRequest.core::getAlbum) != null -> redirect("imageBrowser.html?album=${word.removePrefix("album://")}")
49 word.removePrefix("image://").let { soneRequest.core.getImage(it, false) } != null -> redirect("imageBrowser.html?image=${word.removePrefix("image://")}")
54 val soneNameCache = { sone: Sone -> sone.names() }.memoize()
55 val sonePagination = soneRequest.core.sones
56 .scoreAndPaginate(phrases, soneRequest.core.preferences.postsPerPage) { it.allText(soneNameCache) }
57 .apply { page = soneRequest.parameters["sonePage"].emptyToNull?.toIntOrNull() ?: 0 }
58 val postPagination = cache.get(phrases) {
59 soneRequest.core.sones
60 .flatMap(Sone::getPosts)
61 .filter { Post.FUTURE_POSTS_FILTER.apply(it) }
62 .scoreAndPaginate(phrases, soneRequest.core.preferences.postsPerPage) { it.allText(soneNameCache, soneRequest.core::getReplies) }
63 }.apply { page = soneRequest.parameters["postPage"].emptyToNull?.toIntOrNull() ?: 0 }
65 Logger.normal(SearchPage::class.java, "Finished search for “${soneRequest.parameters["query"]}” in ${System.currentTimeMillis() - startTime}ms.")
66 templateContext["sonePagination"] = sonePagination
67 templateContext["soneHits"] = sonePagination.items
68 templateContext["postPagination"] = postPagination
69 templateContext["postHits"] = postPagination.items
72 private fun <T> Iterable<T>.scoreAndPaginate(phrases: Iterable<Phrase>, postsPerPage: Int, texter: (T) -> String) =
73 map { it to score(texter(it), phrases) }
74 .filter { it.second > 0 }
75 .sortedByDescending { it.second }
77 .paginate(postsPerPage)
79 private fun Sone.names() =
81 listOf(name, firstName, middleName, lastName)
86 private fun Sone.allText(soneNameCache: (Sone) -> String) =
87 (soneNameCache(this) + profile.fields.map { "${it.name} ${it.value}" }.joinToString(" ", " ")).toLowerCase()
89 private fun Post.allText(soneNameCache: (Sone) -> String, getReplies: (String) -> Collection<PostReply>) =
90 (text + recipient.orNull()?.let { " ${soneNameCache(it)}" } + getReplies(id)
91 .filter { PostReply.FUTURE_REPLY_FILTER.apply(it) }
92 .map { "${soneNameCache(it.sone)} ${it.text}" }.joinToString(" ", " ")).toLowerCase()
94 private fun Iterable<Phrase>.indicesFor(text: String, predicate: (Phrase) -> Boolean) =
95 filter(predicate).map(Phrase::phrase).map(String::toLowerCase).flatMap { text.findAll(it) }
97 private fun score(text: String, phrases: Iterable<Phrase>): Double {
98 val requiredPhrases = phrases.count { it.required }
99 val requiredHits = phrases.indicesFor(text, Phrase::required)
100 .map { Math.pow(1 - it / text.length.toDouble(), 2.0) }
102 val optionalHits = phrases.indicesFor(text, Phrase::optional)
103 .map { Math.pow(1 - it / text.length.toDouble(), 2.0) }
105 val forbiddenHits = phrases.indicesFor(text, Phrase::forbidden)
107 return requiredHits * 3 + optionalHits + (requiredHits - requiredPhrases) * 5 - (forbiddenHits * 2)
110 private fun String.findAll(needle: String) =
111 generateSequence(indexOf(needle).takeIf { it > -1 }) { lastPosition ->
113 .let { indexOf(needle, it + 1) }
117 private fun String.parse() =
118 StringEscaper.parseLine(this)
121 it == "+" || it == "-" -> Phrase(it, OPTIONAL)
122 it.startsWith("+") -> Phrase(it.drop(1), REQUIRED)
123 it.startsWith("-") -> Phrase(it.drop(1), FORBIDDEN)
124 else -> Phrase(it, OPTIONAL)
128 private fun redirect(target: String): Nothing = throw RedirectException(target)
130 enum class Optionality {
136 private data class Phrase(val phrase: String, val optionality: Optionality) {
137 val required = optionality == REQUIRED
138 val forbidden = optionality == FORBIDDEN
139 val optional = optionality == OPTIONAL