diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchHistory.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchHistory.kt new file mode 100644 index 0000000000..35d2770bdd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchHistory.kt @@ -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>(emptyList()) + + /** Most recent first, capped at [MAX_RECENT]. */ + val recent: StateFlow> = _recent.asStateFlow() + + private val _saved = MutableStateFlow>(emptyList()) + val saved: StateFlow> = _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) = queries.joinToString(RECORD) { QuerySerializer.serialize(it) } + + fun decodeQueries(raw: String?): List = + raw + ?.split(RECORD) + ?.mapNotNull { line -> QueryParser.parse(line).takeUnless { it.isEmpty } } + .orEmpty() + + fun encodeSaved(searches: List) = + 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 = + 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() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchHistoryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchHistoryTest.kt new file mode 100644 index 0000000000..b4b67fa038 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchHistoryTest.kt @@ -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, + ) : 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 }) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt index 9c3e3b01ad..2de6cda3ec 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt @@ -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>(emptyList()) - val history: StateFlow> = _history.asStateFlow() - - private val _savedSearches = MutableStateFlow>(emptyList()) - val savedSearches: StateFlow> = _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> 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 { - 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) { - val raw = queries.joinToString(SEPARATOR) { QuerySerializer.serialize(it) } - prefs.put(KEY_HISTORY, raw) - } - - private fun loadSaved(): List { - 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) { - 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) }