mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
refactor: search history moves to commons
The cap, the most-recent-first ordering, the de-duplication and the encoding were all written into a desktop `object` bolted to `java.util.prefs`. None of it is desktop-specific — it is a capped list and a labelled list — and that placement is the entire reason Android has never had a search history and the entire reason none of this was tested. `SearchHistory` in commons owns the behaviour; `SearchHistoryStorage` is the two-method seam for where the bytes land. `SearchHistoryStore` keeps its API and becomes the `Preferences` half, so Desktop is unchanged and an existing stored history keeps loading. Two bugs the tests found on arrival: A saved search whose label contained a tab lost the query it named — the label is the one free-text field and the tab is the field separator, so everything after it shifted by one. Labels are now escaped, and only for the two separators and the backslash, so an already-stored label that contains none of them encodes to exactly itself. Re-running a query typed in a different token order made a second history entry. The comparison is on the serialized form now, so two queries that mean the same thing are one entry however they were typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.search
|
||||
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Where a platform keeps two strings. Two reads at startup, one write per change.
|
||||
*
|
||||
* Deliberately this small: the whole of what a search history *is* — the cap, the
|
||||
* most-recent-first ordering, the de-duplication, the encoding — is the same everywhere, and the
|
||||
* only genuine difference between a desktop and a phone here is which file the bytes land in.
|
||||
*/
|
||||
interface SearchHistoryStorage {
|
||||
suspend fun read(key: String): String?
|
||||
|
||||
suspend fun write(
|
||||
key: String,
|
||||
value: String?,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The searches a reader has run, and the ones they kept.
|
||||
*
|
||||
* Desktop has had both for a while; Android had neither, because the code was written into a
|
||||
* desktop `object` sitting on `java.util.prefs`. Nothing about the behaviour was desktop-specific
|
||||
* — it is a capped list and a labelled list — so it moves here whole and Android gains it by
|
||||
* being handed a different [SearchHistoryStorage].
|
||||
*
|
||||
* Queries are stored as their serialized text, which is the same text the box holds. That is what
|
||||
* makes a history entry re-runnable by simply putting it back in the field, and it means a stored
|
||||
* search survives a change to [SearchQuery]'s shape as long as the token language still parses.
|
||||
*/
|
||||
class SearchHistory(
|
||||
private val storage: SearchHistoryStorage,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _recent = MutableStateFlow<List<SearchQuery>>(emptyList())
|
||||
|
||||
/** Most recent first, capped at [MAX_RECENT]. */
|
||||
val recent: StateFlow<List<SearchQuery>> = _recent.asStateFlow()
|
||||
|
||||
private val _saved = MutableStateFlow<List<SavedSearch>>(emptyList())
|
||||
val saved: StateFlow<List<SavedSearch>> = _saved.asStateFlow()
|
||||
|
||||
init {
|
||||
// Fire and forget, as the drawer's collapse state is: the lists read as empty until disk
|
||||
// answers, and the worst case is a recent-searches row that appears a frame late.
|
||||
scope.launch {
|
||||
_recent.value = decodeQueries(readOrNull(KEY_RECENT))
|
||||
_saved.value = decodeSaved(readOrNull(KEY_SAVED))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a search the reader actually ran.
|
||||
*
|
||||
* Re-running something already in the list moves it to the top rather than adding it twice —
|
||||
* compared on the serialized form, so two queries that mean the same thing count as one
|
||||
* however they were typed.
|
||||
*/
|
||||
fun remember(query: SearchQuery) {
|
||||
if (query.isEmpty) return
|
||||
val serialized = QuerySerializer.serialize(query)
|
||||
val next =
|
||||
(listOf(query) + _recent.value.filterNot { QuerySerializer.serialize(it) == serialized })
|
||||
.take(MAX_RECENT)
|
||||
_recent.value = next
|
||||
persist(KEY_RECENT, encodeQueries(next))
|
||||
}
|
||||
|
||||
fun clearRecent() {
|
||||
_recent.value = emptyList()
|
||||
persist(KEY_RECENT, null)
|
||||
}
|
||||
|
||||
/** Keeps [query] under a name the reader chose. */
|
||||
fun save(
|
||||
query: SearchQuery,
|
||||
label: String,
|
||||
) {
|
||||
if (query.isEmpty) return
|
||||
val now = TimeUtils.now()
|
||||
val next = _saved.value + SavedSearch(id = "$now-${_saved.value.size}", label = label, query = query, createdAt = now)
|
||||
_saved.value = next
|
||||
persist(KEY_SAVED, encodeSaved(next))
|
||||
}
|
||||
|
||||
fun forget(id: String) {
|
||||
val next = _saved.value.filter { it.id != id }
|
||||
_saved.value = next
|
||||
persist(KEY_SAVED, encodeSaved(next))
|
||||
}
|
||||
|
||||
private suspend fun readOrNull(key: String): String? =
|
||||
try {
|
||||
storage.read(key)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("SearchHistory") { "Could not read $key: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
private fun persist(
|
||||
key: String,
|
||||
value: String?,
|
||||
) {
|
||||
scope.launch {
|
||||
try {
|
||||
storage.write(key, value)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("SearchHistory") { "Could not write $key: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_RECENT = "search_history"
|
||||
const val KEY_SAVED = "saved_searches"
|
||||
|
||||
/** Long enough to find what you ran this morning, short enough to scan. */
|
||||
const val MAX_RECENT = 20
|
||||
|
||||
private const val RECORD = "\n"
|
||||
private const val FIELD = "\t"
|
||||
|
||||
fun encodeQueries(queries: List<SearchQuery>) = queries.joinToString(RECORD) { QuerySerializer.serialize(it) }
|
||||
|
||||
fun decodeQueries(raw: String?): List<SearchQuery> =
|
||||
raw
|
||||
?.split(RECORD)
|
||||
?.mapNotNull { line -> QueryParser.parse(line).takeUnless { it.isEmpty } }
|
||||
.orEmpty()
|
||||
|
||||
fun encodeSaved(searches: List<SavedSearch>) =
|
||||
searches.joinToString(RECORD) {
|
||||
listOf(escape(it.id), escape(it.label), it.createdAt.toString(), QuerySerializer.serialize(it.query)).joinToString(FIELD)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a reader's label out of the separators.
|
||||
*
|
||||
* A label is the one free-text field here, and a tab in it shifted every field after it —
|
||||
* so naming a saved search "mine\tyours" lost the query it named. Only the two separators
|
||||
* and the escape character itself are touched, so a label that contains none of them
|
||||
* encodes to exactly itself and an already-stored history reads back unchanged.
|
||||
*/
|
||||
private fun escape(value: String) = value.replace("\\", "\\\\").replace(FIELD, "\\t").replace(RECORD, "\\n")
|
||||
|
||||
/** Anything else after a backslash is left alone, so a label stored before [escape] existed survives. */
|
||||
private fun unescape(value: String): String {
|
||||
if ('\\' !in value) return value
|
||||
val out = StringBuilder(value.length)
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
if (c != '\\' || i == value.lastIndex) {
|
||||
out.append(c)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
when (val next = value[i + 1]) {
|
||||
't' -> out.append('\t')
|
||||
'n' -> out.append('\n')
|
||||
'\\' -> out.append('\\')
|
||||
else -> out.append(c).append(next)
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
fun decodeSaved(raw: String?): List<SavedSearch> =
|
||||
raw
|
||||
?.split(RECORD)
|
||||
?.mapNotNull { line ->
|
||||
val parts = line.split(FIELD)
|
||||
if (parts.size < 4) return@mapNotNull null
|
||||
val createdAt = parts[2].toLongOrNull() ?: return@mapNotNull null
|
||||
val query = QueryParser.parse(parts[3])
|
||||
if (query.isEmpty) null else SavedSearch(id = unescape(parts[0]), label = unescape(parts[1]), query = query, createdAt = createdAt)
|
||||
}.orEmpty()
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.search
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The history, over the storage it is now independent of.
|
||||
*
|
||||
* All of this used to live in a desktop `object` bolted to `java.util.prefs`, which is the only
|
||||
* reason Android never had it and the only reason none of it was ever tested.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SearchHistoryTest {
|
||||
private class InMemory(
|
||||
vararg seed: Pair<String, String>,
|
||||
) : SearchHistoryStorage {
|
||||
val values = seed.toMap().toMutableMap()
|
||||
|
||||
override suspend fun read(key: String) = values[key]
|
||||
|
||||
override suspend fun write(
|
||||
key: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value == null) values.remove(key) else values[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private fun q(text: String) = QueryParser.parse(text)
|
||||
|
||||
@Test
|
||||
fun whatWasStoredComesBack() =
|
||||
runTest {
|
||||
val storage = InMemory(SearchHistory.KEY_RECENT to "kind:article bitcoin\n#nostr")
|
||||
val history = SearchHistory(storage, backgroundScope)
|
||||
runCurrent()
|
||||
assertEquals(
|
||||
listOf(30023),
|
||||
history.recent.value
|
||||
.first()
|
||||
.kinds,
|
||||
)
|
||||
assertEquals(listOf("nostr"), history.recent.value[1].hashtags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRepeatedSearchMovesUpRatherThanAppearingTwice() =
|
||||
runTest {
|
||||
val history = SearchHistory(InMemory(), backgroundScope)
|
||||
runCurrent()
|
||||
history.remember(q("bitcoin"))
|
||||
history.remember(q("nostr"))
|
||||
history.remember(q("bitcoin"))
|
||||
runCurrent()
|
||||
assertEquals(2, history.recent.value.size)
|
||||
assertEquals(
|
||||
"bitcoin",
|
||||
history.recent.value
|
||||
.first()
|
||||
.text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoQueriesThatMeanTheSameThingCountAsOne() =
|
||||
runTest {
|
||||
// Compared on the serialized form, so the order the tokens were typed in is not a
|
||||
// second entry.
|
||||
val history = SearchHistory(InMemory(), backgroundScope)
|
||||
runCurrent()
|
||||
history.remember(q("kind:article bitcoin"))
|
||||
history.remember(q("bitcoin kind:article"))
|
||||
runCurrent()
|
||||
assertEquals(1, history.recent.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptyQueryIsNotASearchWorthRemembering() =
|
||||
runTest {
|
||||
val history = SearchHistory(InMemory(), backgroundScope)
|
||||
runCurrent()
|
||||
history.remember(SearchQuery.EMPTY)
|
||||
history.remember(q(" "))
|
||||
runCurrent()
|
||||
assertTrue(history.recent.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theListStopsAtItsCapKeepingTheNewest() =
|
||||
runTest {
|
||||
val history = SearchHistory(InMemory(), backgroundScope)
|
||||
runCurrent()
|
||||
repeat(SearchHistory.MAX_RECENT + 5) { history.remember(q("term$it")) }
|
||||
runCurrent()
|
||||
assertEquals(SearchHistory.MAX_RECENT, history.recent.value.size)
|
||||
assertEquals(
|
||||
"term${SearchHistory.MAX_RECENT + 4}",
|
||||
history.recent.value
|
||||
.first()
|
||||
.text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyChangeReachesTheStorage() =
|
||||
runTest {
|
||||
val storage = InMemory()
|
||||
val history = SearchHistory(storage, backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
history.remember(q("bitcoin"))
|
||||
runCurrent()
|
||||
assertEquals("bitcoin", storage.values[SearchHistory.KEY_RECENT])
|
||||
|
||||
history.clearRecent()
|
||||
runCurrent()
|
||||
assertTrue(SearchHistory.KEY_RECENT !in storage.values, "a cleared history is removed, not stored empty")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSavedSearchKeepsItsNameAcrossTheRoundTrip() =
|
||||
runTest {
|
||||
val storage = InMemory()
|
||||
val history = SearchHistory(storage, backgroundScope)
|
||||
runCurrent()
|
||||
history.save(q("kind:article bitcoin"), "Bitcoin writing")
|
||||
runCurrent()
|
||||
|
||||
val reloaded = SearchHistory(InMemory(SearchHistory.KEY_SAVED to storage.values.getValue(SearchHistory.KEY_SAVED)), backgroundScope)
|
||||
runCurrent()
|
||||
assertEquals(
|
||||
"Bitcoin writing",
|
||||
reloaded.saved.value
|
||||
.single()
|
||||
.label,
|
||||
)
|
||||
assertEquals(
|
||||
listOf(30023),
|
||||
reloaded.saved.value
|
||||
.single()
|
||||
.query.kinds,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLabelWithATabInItDoesNotEatTheQueryBesideIt() =
|
||||
runTest {
|
||||
// The record separator is a tab, so a label carrying one would shift every field
|
||||
// after it. Whatever the encoding does with it, the query must survive.
|
||||
val storage = InMemory()
|
||||
val history = SearchHistory(storage, backgroundScope)
|
||||
runCurrent()
|
||||
history.save(q("bitcoin"), "mine\tyours")
|
||||
runCurrent()
|
||||
|
||||
val reloaded = SearchHistory(InMemory(SearchHistory.KEY_SAVED to storage.values.getValue(SearchHistory.KEY_SAVED)), backgroundScope)
|
||||
runCurrent()
|
||||
assertEquals(
|
||||
"bitcoin",
|
||||
reloaded.saved.value
|
||||
.singleOrNull()
|
||||
?.query
|
||||
?.text,
|
||||
)
|
||||
assertEquals(
|
||||
"mine\tyours",
|
||||
reloaded.saved.value
|
||||
.single()
|
||||
.label,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forgettingRemovesOnlyTheOneNamed() =
|
||||
runTest {
|
||||
val history = SearchHistory(InMemory(), backgroundScope)
|
||||
runCurrent()
|
||||
history.save(q("bitcoin"), "one")
|
||||
history.save(q("nostr"), "two")
|
||||
runCurrent()
|
||||
history.forget(
|
||||
history.saved.value
|
||||
.first()
|
||||
.id,
|
||||
)
|
||||
runCurrent()
|
||||
assertEquals(listOf("two"), history.saved.value.map { it.label })
|
||||
}
|
||||
}
|
||||
+33
-99
@@ -20,118 +20,52 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.search.QueryParser
|
||||
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
|
||||
import com.vitorpamplona.amethyst.commons.search.SavedSearch
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchHistory
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchHistoryStorage
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* Desktop's half of the search history: a `Preferences` node, and nothing else.
|
||||
*
|
||||
* Everything the history *does* — the cap, the ordering, the de-duplication, the encoding — moved
|
||||
* to [SearchHistory] in commons, where Android can have it too. What is left here is the one
|
||||
* genuinely desktop thing, which is where the two strings are kept. The stored format is
|
||||
* unchanged, so an existing history keeps loading.
|
||||
*/
|
||||
object SearchHistoryStore {
|
||||
private val prefs: Preferences = Preferences.userNodeForPackage(SearchHistoryStore::class.java)
|
||||
|
||||
private const val KEY_HISTORY = "search_history"
|
||||
private const val KEY_SAVED = "saved_searches"
|
||||
private const val SEPARATOR = "\n"
|
||||
private const val SAVED_SEPARATOR = "\t"
|
||||
private const val MAX_HISTORY = 20
|
||||
private val storage =
|
||||
object : SearchHistoryStorage {
|
||||
override suspend fun read(key: String): String? = prefs.get(key, "").takeIf { it.isNotBlank() }
|
||||
|
||||
private val _history = MutableStateFlow<List<SearchQuery>>(emptyList())
|
||||
val history: StateFlow<List<SearchQuery>> = _history.asStateFlow()
|
||||
|
||||
private val _savedSearches = MutableStateFlow<List<SavedSearch>>(emptyList())
|
||||
val savedSearches: StateFlow<List<SavedSearch>> = _savedSearches.asStateFlow()
|
||||
|
||||
init {
|
||||
_history.value = loadHistory()
|
||||
_savedSearches.value = loadSaved()
|
||||
}
|
||||
|
||||
fun addToHistory(query: SearchQuery) {
|
||||
if (query.isEmpty) return
|
||||
val serialized = QuerySerializer.serialize(query)
|
||||
val current = _history.value.toMutableList()
|
||||
current.removeAll { QuerySerializer.serialize(it) == serialized }
|
||||
current.add(0, query)
|
||||
if (current.size > MAX_HISTORY) {
|
||||
current.subList(MAX_HISTORY, current.size).clear()
|
||||
override suspend fun write(
|
||||
key: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value.isNullOrBlank()) prefs.remove(key) else prefs.put(key, value)
|
||||
}
|
||||
}
|
||||
_history.value = current.toList()
|
||||
persistHistory(current)
|
||||
}
|
||||
|
||||
fun clearHistory() {
|
||||
_history.value = emptyList()
|
||||
prefs.remove(KEY_HISTORY)
|
||||
}
|
||||
private val store = SearchHistory(storage, CoroutineScope(Dispatchers.Default + SupervisorJob()))
|
||||
|
||||
val history: StateFlow<List<SearchQuery>> get() = store.recent
|
||||
|
||||
val savedSearches get() = store.saved
|
||||
|
||||
fun addToHistory(query: SearchQuery) = store.remember(query)
|
||||
|
||||
fun clearHistory() = store.clearRecent()
|
||||
|
||||
fun saveSearch(
|
||||
query: SearchQuery,
|
||||
label: String,
|
||||
) {
|
||||
if (query.isEmpty) return
|
||||
val saved =
|
||||
SavedSearch(
|
||||
id = System.currentTimeMillis().toString(),
|
||||
label = label,
|
||||
query = query,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
)
|
||||
val current = _savedSearches.value + saved
|
||||
_savedSearches.value = current
|
||||
persistSaved(current)
|
||||
}
|
||||
) = store.save(query, label)
|
||||
|
||||
fun deleteSavedSearch(id: String) {
|
||||
val current = _savedSearches.value.filter { it.id != id }
|
||||
_savedSearches.value = current
|
||||
persistSaved(current)
|
||||
}
|
||||
|
||||
private fun loadHistory(): List<SearchQuery> {
|
||||
val raw = prefs.get(KEY_HISTORY, "")
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return raw
|
||||
.split(SEPARATOR)
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { line ->
|
||||
val parsed = QueryParser.parse(line)
|
||||
if (parsed.isEmpty) null else parsed
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistHistory(queries: List<SearchQuery>) {
|
||||
val raw = queries.joinToString(SEPARATOR) { QuerySerializer.serialize(it) }
|
||||
prefs.put(KEY_HISTORY, raw)
|
||||
}
|
||||
|
||||
private fun loadSaved(): List<SavedSearch> {
|
||||
val raw = prefs.get(KEY_SAVED, "")
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return raw
|
||||
.split(SEPARATOR)
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split(SAVED_SEPARATOR)
|
||||
if (parts.size < 4) return@mapNotNull null
|
||||
val id = parts[0]
|
||||
val label = parts[1]
|
||||
val createdAt = parts[2].toLongOrNull() ?: return@mapNotNull null
|
||||
val queryText = parts[3]
|
||||
val query = QueryParser.parse(queryText)
|
||||
if (query.isEmpty) return@mapNotNull null
|
||||
SavedSearch(id = id, label = label, query = query, createdAt = createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistSaved(searches: List<SavedSearch>) {
|
||||
val raw =
|
||||
searches.joinToString(SEPARATOR) { s ->
|
||||
listOf(s.id, s.label, s.createdAt.toString(), QuerySerializer.serialize(s.query))
|
||||
.joinToString(SAVED_SEPARATOR)
|
||||
}
|
||||
prefs.put(KEY_SAVED, raw)
|
||||
}
|
||||
fun deleteSavedSearch(id: String) = store.forget(id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user