feat(Scout): init

This commit is contained in:
LavaDesu 2025-05-29 03:12:05 +10:00
commit 9226686948
Signed by: cilly
GPG key ID: 6500251E087653C9
24 changed files with 1224 additions and 0 deletions

View file

@ -0,0 +1,17 @@
version = "1.0.0"
description = "Backported and improved search functionality"
aliucord {
// Changelog of your plugin
changelog.set("""
1.0.0 - Initial release >w<
""".trimIndent())
// Add additional authors to this plugin
// author("Name", 0)
// author("Name", 0)
// Excludes this plugin from the updater, meaning it won't show up for users.
// Set this if the plugin is unfinished
excludeFromUpdaterJson.set(true)
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="youImportedTheWrongR" />

View file

@ -0,0 +1,5 @@
package com.discord.restapi
// Stub
@Suppress("ClassName")
abstract class `RequiredHeadersInterceptor$HeadersProvider` {}

View file

@ -0,0 +1,13 @@
package moe.lava.awoocord.scout
import com.discord.utilities.search.query.FilterType
object FilterTypeExtension {
lateinit var BEFORE: FilterType
lateinit var DURING: FilterType
lateinit var AFTER: FilterType
lateinit var SORT: FilterType
lateinit var dates: Array<FilterType>
lateinit var values: Array<FilterType>
}

View file

@ -0,0 +1,332 @@
package moe.lava.awoocord.scout
import android.content.Context
import android.content.res.Resources
import androidx.core.content.ContextCompat
import com.aliucord.Utils
import com.aliucord.annotations.AliucordPlugin
import com.aliucord.entities.Plugin
import com.aliucord.patcher.PreHook
import com.aliucord.patcher.after
import com.aliucord.patcher.before
import com.discord.BuildConfig
import com.discord.restapi.RequiredHeadersInterceptor
import com.discord.restapi.RequiredHeadersInterceptor.HeadersProvider
import com.discord.restapi.RestAPIBuilder
import com.discord.simpleast.core.parser.Parser
import com.discord.simpleast.core.parser.Rule
import com.discord.stores.StoreSearch
import com.discord.stores.StoreSearchInput
import com.discord.utilities.rest.RestAPI.AppHeadersProvider
import com.discord.utilities.search.network.`SearchFetcher$getRestObservable$3`
import com.discord.utilities.search.query.FilterType
import com.discord.utilities.search.query.node.QueryNode
import com.discord.utilities.search.query.node.content.ContentNode
import com.discord.utilities.search.query.node.filter.FilterNode
import com.discord.utilities.search.query.parsing.QueryParser
import com.discord.utilities.search.strings.SearchStringProvider
import com.discord.utilities.search.suggestion.SearchSuggestionEngine
import com.discord.utilities.search.suggestion.entries.FilterSuggestion
import com.discord.utilities.search.suggestion.entries.SearchSuggestion
import com.discord.widgets.search.suggestions.WidgetSearchSuggestionsAdapter
import com.franmontiel.persistentcookiejar.PersistentCookieJar
import com.franmontiel.persistentcookiejar.cache.SetCookieCache
import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor
import moe.lava.awoocord.scout.api.SearchAPIInterface
import moe.lava.awoocord.scout.parsing.DateNode
import moe.lava.awoocord.scout.parsing.SortNode
import moe.lava.awoocord.scout.parsing.UserIdNode
import moe.lava.awoocord.scout.ui.DatePickerFragment
import moe.lava.awoocord.scout.ui.ScoutResource
import moe.lava.awoocord.scout.ui.ScoutSearchStringProvider
@AliucordPlugin(requiresRestart = false)
@Suppress("unused", "unchecked_cast")
class Scout : Plugin() {
lateinit var ssProvider: ScoutSearchStringProvider
lateinit var searchApi: SearchAPIInterface
override fun start(context: Context) {
ssProvider = ScoutSearchStringProvider(context)
searchApi = buildSearchApi(context)
extendFilterType()
patchQueryParser()
patchQuery()
patchSearchUI(context)
}
override fun stop(context: Context) {
resetFilterType()
patcher.unpatchAll()
}
// Creates a new custom search API implementation, for the extra `min_id` param in search queries
private fun buildSearchApi(context: Context): SearchAPIInterface {
@Suppress("cast_never_succeeds")
val appHeadersProvider = AppHeadersProvider.INSTANCE as HeadersProvider
val requiredHeadersInterceptor = RequiredHeadersInterceptor(appHeadersProvider)
val persistentCookieJar = PersistentCookieJar(SetCookieCache(), SharedPrefsCookiePersistor(context))
val restAPIBuilder = RestAPIBuilder(BuildConfig.HOST_API, persistentCookieJar)
return RestAPIBuilder.`build$default`(
restAPIBuilder,
SearchAPIInterface::class.java,
false,
0L,
listOf(requiredHeadersInterceptor),
"client_base",
false,
null,
102,
null
) as SearchAPIInterface
}
private val origFilterTypes: Array<FilterType>? = null
// Creates new pseudo-values of the `FilterType` enum for date filters
@Suppress("LocalVariableName")
private fun extendFilterType() {
val cls = FilterType::class.java
val constructor = cls.declaredConstructors[0]
constructor.isAccessible = true
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
val values = field.get(null) as Array<FilterType>
var nextIdx = values.size
val BEFORE = constructor.newInstance("BEFORE", nextIdx++) as FilterType
val DURING = constructor.newInstance("DURING", nextIdx++) as FilterType
val AFTER = constructor.newInstance("AFTER", nextIdx++) as FilterType
val SORT = constructor.newInstance("SORT", nextIdx) as FilterType
FilterTypeExtension.BEFORE = BEFORE
FilterTypeExtension.DURING = DURING
FilterTypeExtension.AFTER = AFTER
FilterTypeExtension.SORT = SORT
FilterTypeExtension.dates = arrayOf(BEFORE, DURING, AFTER)
FilterTypeExtension.values = arrayOf(BEFORE, DURING, AFTER, SORT)
val newValues = values.toMutableList()
newValues.addAll(FilterTypeExtension.values)
field.set(null, newValues.toTypedArray())
}
private fun resetFilterType() {
if (origFilterTypes == null)
return logger.error("No unpatched filter types?", null)
val cls = FilterType::class.java
val field = cls.getDeclaredField("\$VALUES")
field.isAccessible = true
field.set(null, origFilterTypes)
}
// Patches the search query to also insert `min_id`, required for searching "after:" and "during:"
private fun patchQuery() {
patcher.patch(
`SearchFetcher$getRestObservable$3`::class.java.getDeclaredMethod("call", Integer::class.java),
PreHook { param ->
val self = param.thisObject as `SearchFetcher$getRestObservable$3`<*, *>
val retryAttempts = param.args[0] as Int?
val params = self.`$searchQuery`.params
val maxID = self.`$oldestMessageId`?.let { listOf(it.toString()) } ?: params["max_id"]
param.result = if (self.`$searchTarget`.type == StoreSearch.SearchTarget.Type.GUILD)
searchApi.searchGuildMessages(
self.`$searchTarget`.id,
params["min_id"],
maxID,
params["author_id"],
params["mentions"],
params["channel_id"],
params["has"],
params["content"],
retryAttempts,
self.`$searchQuery`.includeNsfw,
listOf("timestamp"),
params["sort_order"]
)
else
searchApi.searchChannelMessages(
self.`$searchTarget`.id,
params["min_id"],
maxID,
params["author_id"],
params["mentions"],
params["has"],
params["content"],
retryAttempts,
self.`$searchQuery`.includeNsfw,
listOf("timestamp"),
params["sort_order"]
)
}
)
}
// Patch parser for date parsing
private fun patchQueryParser() {
patcher.after<QueryParser>(SearchStringProvider::class.java) {
// We need to access and insert into the rules before the rest
val field = Parser::class.java.getDeclaredField("rules").apply { isAccessible = true }
val rules = field.get(this) as ArrayList<Rule<Context, QueryNode, Any>>
rules.addAll(0, listOf(
UserIdNode.getUserIdRule(),
DateNode.getBeforeRule(ssProvider.beforeFilterString),
DateNode.getDuringRule(ssProvider.duringFilterString),
DateNode.getAfterRule(ssProvider.afterFilterString),
DateNode.getDateRule(),
SortNode.getFilterRule(ssProvider.sortFilterString),
SortNode.getSortRule(ssProvider),
))
}
}
// This is probably the worst bit of this plugin
private fun patchSearchUI(context: Context) {
// Run when a filter suggestion is clicked
// Most of the code is copied from its implementation
// Patch needed to support the new filter types
patcher.before<StoreSearchInput>(
"onFilterClicked",
FilterType::class.java,
SearchStringProvider::class.java,
List::class.java,
) { param ->
val filter = param.args[0] as FilterType
if (filter !in FilterTypeExtension.values)
return@before; // Exit if not an extended filter type
val replaceAndPublish = StoreSearchInput::class.java.getDeclaredMethod(
"replaceAndPublish",
Int::class.javaPrimitiveType!!,
List::class.java,
List::class.java
)
replaceAndPublish.isAccessible = true
val getAnswerReplacementStart = StoreSearchInput::class.java.getDeclaredMethod(
"getAnswerReplacementStart",
List::class.java,
)
getAnswerReplacementStart.isAccessible = true
// Original implementation
val filterNode = FilterNode(filter, ssProvider.stringFor(filter))
val list = (param.args[2] as List<QueryNode>).toMutableList()
val lastIndex = if (list.isEmpty()) {
0
} else if (list.last() is ContentNode)
list.lastIndex
else
list.size
// Open a Date Picker
if (filter in FilterTypeExtension.dates) {
replaceAndPublish.invoke(this, lastIndex, listOf(filterNode), list)
DatePickerFragment.open(Utils.appActivity.supportFragmentManager) {
replaceAndPublish.invoke(this,
getAnswerReplacementStart.invoke(this, list),
listOf(filterNode, DateNode(it)),
list
);
}
}
if (filter == FilterTypeExtension.SORT)
replaceAndPublish.invoke(this,
lastIndex,
listOf(filterNode, SortNode(ssProvider.sortOldString)),
list
);
param.result = null
}
// Patch to set icons
@Suppress("ResourceType")
patcher.before<WidgetSearchSuggestionsAdapter.FilterViewHolder>(
"getIconDrawable",
Context::class.java,
FilterType::class.java
) { param ->
val type = param.args[1] as FilterType
if (type in FilterTypeExtension.dates)
param.result = ContextCompat.getDrawable(context, ScoutResource.DRAWABLE_IC_CLOCK)
if (type == FilterTypeExtension.SORT)
param.result = ContextCompat.getDrawable(context, ScoutResource.DRAWABLE_IC_SORT_WHITE)
}
// Patch for retrieving sample filter answer/placeholder
patcher.before<WidgetSearchSuggestionsAdapter.FilterViewHolder>(
"getAnswerText",
FilterType::class.java
) { param ->
val type = param.args[0] as FilterType
if (type in FilterTypeExtension.dates)
param.result = ssProvider.getIdentifier("search_answer_date")
if (type == FilterTypeExtension.SORT)
param.result = ScoutResource.SORT_ANSWER
}
// Patch for retrieving filter name
patcher.before<WidgetSearchSuggestionsAdapter.FilterViewHolder>(
"getFilterText",
FilterType::class.java
) { param ->
val type = param.args[0] as FilterType
val res = when (type) {
FilterTypeExtension.BEFORE -> ssProvider.getIdentifier("search_filter_before")
FilterTypeExtension.DURING -> ssProvider.getIdentifier("search_filter_during")
FilterTypeExtension.AFTER -> ssProvider.getIdentifier("search_filter_after")
FilterTypeExtension.SORT -> ScoutResource.SORT_FILTER
else -> null
}
res?.let { param.result = it }
}
// Patch formatting utils to use our custom lowercase strings
// This is called by FilterViewHolder.onConfigure, using the results from getAnswerText and getFilterText
patcher.patch(
b.a.k.b::class.java.getDeclaredMethod("c",
Resources::class.java,
Int::class.javaPrimitiveType!!,
Array::class.java,
Function1::class.java
),
PreHook { param ->
val resID = param.args[1] as Int
val objArr = param.args[2] as Array<*>
val override = when (resID) {
ScoutResource.SORT_FILTER -> ssProvider.sortFilterString
ScoutResource.SORT_ANSWER -> ssProvider.sortOldString
else -> null
}
override?.let {
// Why invoke? Becuase I can't for the life of me get Function1 to cast properly
param.result = b.a.k.b::class.java.getDeclaredMethod("g",
CharSequence::class.java,
Array::class.java,
Function1::class.java
).invoke(null, it, objArr.copyOf(), param.args[3])
}
}
)
// Patch to add our new filters into the initial suggestions
patcher.after<SearchSuggestionEngine>(
"getFilterSuggestions",
CharSequence::class.java,
SearchStringProvider::class.java,
Boolean::class.javaPrimitiveType!!,
) { param ->
val query = param.args[0] as CharSequence
val res = (param.result as List<SearchSuggestion>).toMutableList()
for (type in FilterTypeExtension.values) {
val st = ssProvider.stringFor(type) + ":"
if (st.contains(query))
res.add(FilterSuggestion(type))
}
param.result = res.toList()
}
}
}

View file

@ -0,0 +1,44 @@
package moe.lava.awoocord.scout.api
import com.discord.models.domain.ModelSearchResponse
import i0.f0.f
import i0.f0.s
import i0.f0.t
import rx.Observable
// io.f0.f = retrofit @GET
// io.f0.s = retrofit @Path
// io.f0.t = retrofit @Query
interface SearchAPIInterface {
@f("channels/{channelId}/messages/search")
fun searchChannelMessages(
@s("channelId") channelId: Long,
@t("min_id") minId: List<String>?,
@t("max_id") maxId: List<String>?,
@t("author_id") authorId: List<String>?,
@t("mentions") mentions: List<String>?,
@t("has") has: List<String>?,
@t("content") content: List<String>?,
@t("attempts") attempts: Int?,
@t("include_nsfw") includeNsfw: Boolean?,
@t("sort_by") sortBy: List<String>?, // "timestamp" is one, not sure about any other sort types
@t("sort_order") sortOrder: List<String>?, // "asc" or "desc"
): Observable<ModelSearchResponse?>
@f("guilds/{guildId}/messages/search")
fun searchGuildMessages(
@s("guildId") guildId: Long,
@t("min_id") minId: List<String>?,
@t("max_id") maxId: List<String>?,
@t("author_id") authorId: List<String>?,
@t("mentions") mentions: List<String>?,
@t("channel_id") channelId: List<String>?,
@t("has") has: List<String>?,
@t("content") content: List<String>?,
@t("attempts") attempts: Int?,
@t("include_nsfw") includeNsfw: Boolean?,
@t("sort_by") sortBy: List<String>?,
@t("sort_order") sortOrder: List<String>?,
): Observable<ModelSearchResponse?>
}

View file

@ -0,0 +1,75 @@
package moe.lava.awoocord.scout.parsing
import com.discord.simpleast.core.parser.ParseSpec
import com.discord.utilities.SnowflakeUtils
import com.discord.utilities.search.network.SearchQuery
import com.discord.utilities.search.query.FilterType
import com.discord.utilities.search.query.node.answer.AnswerNode
import com.discord.utilities.search.query.node.filter.FilterNode
import com.discord.utilities.search.validation.SearchData
import moe.lava.awoocord.scout.FilterTypeExtension
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.regex.Pattern
class DateNode(private val date: Long?, private val unparsed: String) : AnswerNode() {
constructor(unparsed: String) : this(fmt.parse(unparsed)?.time, unparsed)
companion object {
val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
val regex: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}", Pattern.UNICODE_CASE)
fun getDateRule(): ParserRule {
return SimpleParserRule(regex) { matcher, parser, obj ->
checkNotNull(matcher) { "matcher" }
checkNotNull(parser) { "parser" }
val match = matcher.group()
val date = fmt.parse(match)
val node = DateNode(date?.time, match)
ParseSpec(node, obj)
}
}
private fun getFilterRule(str: String, type: FilterType): ParserRule {
val regex = Pattern.compile("^\\s*?(${str}):", 64);
return SimpleParserRule(regex) { _, _, obj ->
ParseSpec(FilterNode(type, str), obj)
}
}
fun getBeforeRule(str: String): ParserRule = getFilterRule(str, FilterTypeExtension.BEFORE)
fun getDuringRule(str: String): ParserRule = getFilterRule(str, FilterTypeExtension.DURING)
fun getAfterRule(str: String): ParserRule = getFilterRule(str, FilterTypeExtension.AFTER)
}
override fun getValidFilters(): Set<FilterType> = FilterTypeExtension.dates.toSet()
override fun isValid(searchData: SearchData?): Boolean = date != null
override fun getText(): CharSequence? = unparsed
private val snowflake: String?
get() = date?.let { SnowflakeUtils.fromTimestamp(date).toString() }
private val nextDaySnowflake: String?
get() = date?.let { SnowflakeUtils.fromTimestamp(date + 86_400_000).toString() }
override fun updateQuery(
builder: SearchQuery.Builder?,
searchData: SearchData?,
filterType: FilterType?
) {
checkNotNull(builder) { "queryBuilder" }
checkNotNull(date) { "date" }
when (filterType) {
FilterTypeExtension.BEFORE -> {
builder.appendParam("max_id", snowflake)
}
FilterTypeExtension.AFTER -> {
builder.appendParam("min_id", nextDaySnowflake)
}
FilterTypeExtension.DURING -> {
builder.appendParam("min_id", snowflake)
builder.appendParam("max_id", nextDaySnowflake)
}
else -> return
}
}
}

View file

@ -0,0 +1,29 @@
package moe.lava.awoocord.scout.parsing
import android.content.Context
import com.discord.simpleast.core.parser.ParseSpec
import com.discord.simpleast.core.parser.Parser
import com.discord.simpleast.core.parser.Rule
import com.discord.utilities.search.query.node.QueryNode
import java.util.regex.Matcher
import java.util.regex.Pattern
internal typealias ParserRule = Rule<Context, QueryNode, Any>
internal class SimpleParserRule(
regex: Pattern,
private val parseMethod: (
matcher: Matcher,
parser: Parser<Context, in QueryNode, Any>,
obj: Any
) -> ParseSpec<Context, Any>
) : ParserRule(regex) {
override fun parse(
matcher: Matcher?,
parser: Parser<Context, in QueryNode, in Any>,
obj: Any
): ParseSpec<Context, in Any> {
checkNotNull(matcher) { "matcher" }
checkNotNull(parser) { "parser" }
return parseMethod(matcher, parser, obj)
}
}

View file

@ -0,0 +1,46 @@
package moe.lava.awoocord.scout.parsing
import android.content.Context
import com.discord.simpleast.core.parser.ParseSpec
import com.discord.simpleast.core.parser.Rule
import com.discord.utilities.search.network.SearchQuery
import com.discord.utilities.search.query.FilterType
import com.discord.utilities.search.query.node.QueryNode
import com.discord.utilities.search.query.node.answer.AnswerNode
import com.discord.utilities.search.query.node.filter.FilterNode
import com.discord.utilities.search.validation.SearchData
import moe.lava.awoocord.scout.FilterTypeExtension
import moe.lava.awoocord.scout.ui.ScoutSearchStringProvider
import java.util.regex.Pattern
class SortNode(private val text: String): AnswerNode() {
companion object {
fun getSortRule(ssProvider: ScoutSearchStringProvider): Rule<Context, QueryNode, Any> {
val regexStr = "^\\s*(${ssProvider.sortOldString})"
val regex = Pattern.compile(regexStr, Pattern.UNICODE_CASE)
return SimpleParserRule(regex) { _, _, obj ->
ParseSpec(SortNode(ssProvider.sortOldString), obj)
}
}
fun getFilterRule(str: String): ParserRule {
val regex = Pattern.compile("^\\s*?(${str}):", 64);
return SimpleParserRule(regex) { _, _, obj ->
ParseSpec(FilterNode(FilterTypeExtension.SORT, str), obj)
}
}
}
override fun getValidFilters() = setOf(FilterTypeExtension.SORT)
override fun isValid(searchData: SearchData?) = true
override fun getText() = this.text
override fun updateQuery(
builder: SearchQuery.Builder?,
searchData: SearchData?,
filterType: FilterType?
) {
checkNotNull(builder) { "queryBuilder" }
builder.appendParam("sort_order", "asc")
}
}

View file

@ -0,0 +1,41 @@
package moe.lava.awoocord.scout.parsing
import android.content.Context
import com.discord.simpleast.core.parser.ParseSpec
import com.discord.simpleast.core.parser.Rule
import com.discord.utilities.search.network.SearchQuery
import com.discord.utilities.search.query.FilterType
import com.discord.utilities.search.query.node.QueryNode
import com.discord.utilities.search.query.node.answer.AnswerNode
import com.discord.utilities.search.validation.SearchData
import java.util.regex.Pattern
class UserIdNode(private val userID: String) : AnswerNode() {
companion object {
fun getUserIdRule(): Rule<Context, QueryNode, Any> {
val regex = Pattern.compile("^\\d{17,19}", Pattern.UNICODE_CASE)
return SimpleParserRule(regex) { matcher, _, obj ->
ParseSpec(UserIdNode(matcher.group()), obj)
}
}
}
override fun getValidFilters() = setOf(FilterType.FROM, FilterType.MENTIONS)
override fun isValid(searchData: SearchData?) = true
override fun getText() = userID.toString()
override fun updateQuery(
builder: SearchQuery.Builder?,
searchData: SearchData?,
filterType: FilterType?
) {
checkNotNull(builder) { "queryBuilder" }
checkNotNull(searchData) { "searchData" }
val str = when (filterType) {
FilterType.FROM -> "author_id"
FilterType.MENTIONS -> "mentions"
else -> return
}
builder.appendParam(str, userID)
}
}

View file

@ -0,0 +1,34 @@
package moe.lava.awoocord.scout.ui
import android.app.DatePickerDialog
import android.app.Dialog
import android.os.Bundle
import android.widget.DatePicker
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import java.util.Calendar
class DatePickerFragment(
private val callback: (String) -> Unit
) : DialogFragment(), DatePickerDialog.OnDateSetListener {
companion object {
fun open(fragmentManager: FragmentManager, callback: (date: String) -> Unit) {
DatePickerFragment(callback).show(fragmentManager, "datePicker")
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val c = Calendar.getInstance()
val year = c.get(Calendar.YEAR)
val month = c.get(Calendar.MONTH)
val day = c.get(Calendar.DAY_OF_MONTH)
android.app.AlertDialog.THEME_DEVICE_DEFAULT_DARK
return DatePickerDialog(requireContext(), this, year, month, day)
}
override fun onDateSet(picker: DatePicker, year: Int, month: Int, day: Int) {
callback("%04d-%02d-%02d".format(year, month, day))
}
}

View file

@ -0,0 +1,8 @@
package moe.lava.awoocord.scout.ui
object ScoutResource {
const val SORT_FILTER = 0xfffffff0.toInt()
const val SORT_ANSWER = 0xfffffff1.toInt()
const val DRAWABLE_IC_CLOCK = 0x7f0803bb
const val DRAWABLE_IC_SORT_WHITE =0x7f080586
}

View file

@ -0,0 +1,35 @@
package moe.lava.awoocord.scout.ui
import android.content.Context
import com.discord.utilities.search.query.FilterType
import moe.lava.awoocord.scout.FilterTypeExtension
private fun String.decapitalise(context: Context) =
this.replaceFirstChar { it.lowercase(context.resources.configuration.locales[0]) }
class ScoutSearchStringProvider(private val context: Context) {
fun getIdentifier(name: String) =
context.resources.getIdentifier(name, "string", "com.discord")
fun getString(name: String) =
context.getString(getIdentifier(name))
fun stringFor(type: FilterType) = when (type) {
FilterTypeExtension.BEFORE -> beforeFilterString
FilterTypeExtension.DURING -> duringFilterString
FilterTypeExtension.AFTER -> afterFilterString
FilterTypeExtension.SORT -> sortFilterString
else -> throw IllegalArgumentException("invalid extended filter type")
}
// Surprising!! Discord has localised strings of these
val beforeFilterString: String
get() = getString("search_filter_before")
val duringFilterString: String
get() = getString("search_filter_during")
val afterFilterString: String
get() = getString("search_filter_after")
val sortFilterString: String
get() = getString("sort").decapitalise(context)
val sortOldString: String
get() = getString("search_oldest_short").decapitalise(context)
}