Skip to content

Commit bd27643

Browse files
committed
refactor(browse): extract saved searches
"savedFilters" in Anki Desktop This extracts & documents the API in preparation for improving the UI when we move to a Material 3 SearchView Issue 18709
1 parent a000254 commit bd27643

File tree

7 files changed

+259
-44
lines changed

7 files changed

+259
-44
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import com.ichi2.anki.browser.IdsFile
6464
import com.ichi2.anki.browser.SaveSearchResult
6565
import com.ichi2.anki.browser.SharedPreferencesLastDeckIdRepository
6666
import com.ichi2.anki.browser.registerFindReplaceHandler
67+
import com.ichi2.anki.browser.search.savedFilters
6768
import com.ichi2.anki.browser.toCardBrowserLaunchOptions
6869
import com.ichi2.anki.common.annotations.NeedsTest
6970
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
@@ -382,16 +383,15 @@ open class CardBrowser :
382383
SavedBrowserSearchesDialogFragment.TYPE_SEARCH_SELECTED -> {
383384
Timber.d("Selecting saved search selection named: %s", searchName)
384385
launchCatchingTask {
385-
viewModel.savedSearches()[searchName]?.also { savedSearch ->
386-
Timber.d("OnSelection using search terms: %s", savedSearch)
387-
searchForQuery(savedSearch)
388-
}
386+
val search = viewModel.savedSearches().find { it.name == searchName } ?: return@launchCatchingTask
387+
Timber.d("OnSelection using search terms: %s", search.query)
388+
searchForQuery(search.query)
389389
}
390390
}
391391
SavedBrowserSearchesDialogFragment.TYPE_SEARCH_REMOVED -> {
392392
Timber.d("Removing saved search named: %s", searchName)
393393
launchCatchingTask {
394-
val updatedFilters = viewModel.removeSavedSearch(searchName)
394+
val (_, updatedFilters) = viewModel.removeSavedSearch(searchName)
395395
if (updatedFilters.isEmpty()) {
396396
mySearchesItem!!.isVisible = false
397397
}
@@ -788,8 +788,7 @@ open class CardBrowser :
788788
saveSearchItem = menu.findItem(R.id.action_save_search)
789789
saveSearchItem?.isVisible = false // the searchview's query always starts empty.
790790
mySearchesItem = menu.findItem(R.id.action_list_my_searches)
791-
val savedFiltersObj = viewModel.savedSearchesUnsafe(getColUnsafe)
792-
mySearchesItem!!.isVisible = savedFiltersObj.isNotEmpty()
791+
mySearchesItem!!.isVisible = getColUnsafe.config.savedFilters.isNotEmpty()
793792
searchItem = menu.findItem(R.id.action_search)
794793
searchItem!!.setOnActionExpandListener(
795794
object : MenuItem.OnActionExpandListener {

AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ import com.ichi2.anki.browser.CardBrowserViewModel.ToggleSelectionState.SELECT_N
4949
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ALL_FIELDS_AS_FIELD
5050
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.TAGS_AS_FIELD
5151
import com.ichi2.anki.browser.RepositionCardsRequest.RepositionData
52+
import com.ichi2.anki.browser.search.SavedSearches
53+
import com.ichi2.anki.browser.search.SavedSearches.SavedSearch
5254
import com.ichi2.anki.common.annotations.NeedsTest
5355
import com.ichi2.anki.export.ExportDialogFragment.ExportType
5456
import com.ichi2.anki.launchCatchingIO
@@ -1009,39 +1011,14 @@ class CardBrowserViewModel(
10091011
if (it == -1) null else it
10101012
}
10111013

1012-
private suspend fun updateSavedSearches(func: MutableMap<String, String>.() -> Unit): Map<String, String> {
1013-
val filters = savedSearches().toMutableMap()
1014-
func(filters)
1015-
withCol { config.set("savedFilters", filters) }
1016-
return filters
1017-
}
1018-
1019-
suspend fun savedSearches(): Map<String, String> = withCol { config.get("savedFilters") } ?: hashMapOf()
1014+
suspend fun savedSearches(): List<SavedSearch> = SavedSearches.loadFromConfig()
10201015

1021-
fun savedSearchesUnsafe(col: com.ichi2.anki.libanki.Collection): Map<String, String> = col.config.get("savedFilters") ?: hashMapOf()
1022-
1023-
suspend fun removeSavedSearch(searchName: String): Map<String, String> {
1024-
Timber.d("removing user search")
1025-
return updateSavedSearches {
1026-
remove(searchName)
1027-
}
1028-
}
1016+
suspend fun removeSavedSearch(searchName: String) = SavedSearches.removeByName(searchName)
10291017

10301018
@CheckResult
1031-
suspend fun saveSearch(
1032-
searchName: String,
1033-
searchTerms: String,
1034-
): SaveSearchResult {
1035-
Timber.d("saving user search")
1036-
var alreadyExists = false
1037-
updateSavedSearches {
1038-
if (get(searchName) != null) {
1039-
alreadyExists = true
1040-
} else {
1041-
set(searchName, searchTerms)
1042-
}
1043-
}
1044-
return if (alreadyExists) SaveSearchResult.ALREADY_EXISTS else SaveSearchResult.SUCCESS
1019+
suspend fun saveSearch(search: SavedSearch): SaveSearchResult {
1020+
val (searchAdded, _) = SavedSearches.add(search)
1021+
return if (searchAdded) SaveSearchResult.SUCCESS else SaveSearchResult.ALREADY_EXISTS
10451022
}
10461023

10471024
/** Ignores any values before [initCompleted] is set */
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.browser.search
18+
19+
import com.ichi2.anki.CollectionManager.withCol
20+
import com.ichi2.anki.browser.search.SavedSearches.SavedSearch
21+
import com.ichi2.anki.libanki.Config
22+
import timber.log.Timber
23+
24+
private typealias SavedSearchList = List<SavedSearch>
25+
26+
/**
27+
* Manages saved searches (named search queries in the Card Browser)
28+
*
29+
* Named 'Saved Searches' in Anki Desktop
30+
*
31+
* Searches are shared between all Anki clients in the collection, are unordered and are
32+
* case-sensitive: 'A' and 'a' are different searches with the ordering: `["A", "Z", "a"]`
33+
*
34+
* @see SavedSearch
35+
* @see savedFilters
36+
*/
37+
object SavedSearches {
38+
/**
39+
* Returns the list of [saved searches][SavedSearch] stored in the Anki collection config.
40+
*/
41+
suspend fun loadFromConfig(): SavedSearchList = withCol { config.savedFilters }
42+
43+
/**
44+
* Updates the list of [saved searches][SavedSearch] stored in the Anki collection config.
45+
*
46+
* Ordering is NOT preserved
47+
*/
48+
suspend fun saveToConfig(values: SavedSearchList) = withCol { config.savedFilters = values }
49+
50+
/**
51+
* Adds a saved search to the Anki collection config
52+
*
53+
* @return a pair: `false` if a search with the given name already exists,
54+
* `true` if the search was added.
55+
*
56+
* The second element of the pair is the updated list of saved searches.
57+
*/
58+
suspend fun add(savedSearch: SavedSearch): Pair<Boolean, SavedSearchList> {
59+
Timber.i("saving user search")
60+
val values = loadFromConfig()
61+
if (values.any { it.name == savedSearch.name }) return false to values
62+
val updatedValues = values + savedSearch
63+
saveToConfig(updatedValues)
64+
return true to loadFromConfig()
65+
}
66+
67+
/**
68+
* Removes a saved search from the Anki collection by name
69+
*
70+
* @return a pair: `true` if the searches were updated, `false` if the name was not found
71+
*
72+
* The second element of the pair is the updated list of saved searches.
73+
*/
74+
suspend fun removeByName(searchName: String): Pair<Boolean, SavedSearchList> {
75+
Timber.i("removing saved search")
76+
val originalValues = loadFromConfig()
77+
val updatedValues = originalValues.filter { it.name != searchName }
78+
saveToConfig(updatedValues)
79+
val listUpdated = originalValues.size != updatedValues.size
80+
return listUpdated to updatedValues
81+
}
82+
83+
/** Removes all saved searches from the Anki collection */
84+
suspend fun clear() = saveToConfig(emptyList())
85+
86+
/**
87+
* A named query for the Card Browser
88+
*
89+
* Selecting a saved search quickly allows a user to either:
90+
* - search the given query
91+
* - add additional terms to the query before searching
92+
*/
93+
data class SavedSearch(
94+
val name: String,
95+
val query: String,
96+
)
97+
}
98+
99+
/**
100+
* The list of saved searches in the Anki Collection
101+
*
102+
* Ordering is NOT preserved in Anki Desktop. Searches are ordered based on the name and are
103+
* case-sensitive
104+
*/
105+
var Config.savedFilters: SavedSearchList
106+
get() =
107+
(get<Map<String, String>>("savedFilters") ?: hashMapOf())
108+
.map { (k, v) -> SavedSearch(k, v) }
109+
set(value) {
110+
set("savedFilters", value.toMap())
111+
}
112+
113+
fun SavedSearchList.toMap(): Map<String, String> = associate { it.name to it.query }

AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import androidx.fragment.app.setFragmentResult
2424
import com.google.android.material.snackbar.Snackbar
2525
import com.ichi2.anki.CardBrowser
2626
import com.ichi2.anki.R
27+
import com.ichi2.anki.browser.search.SavedSearches.SavedSearch
2728
import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.ARG_SEARCH_QUERY
2829
import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.ARG_SEARCH_QUERY_NAME
2930
import com.ichi2.anki.launchCatchingTask
@@ -112,12 +113,14 @@ fun CardBrowser.registerSaveSearchHandler() {
112113
)
113114
return@setFragmentResultListener
114115
}
115-
val savedSearchQuery =
116-
bundle.getString(ARG_SEARCH_QUERY)
117-
?: return@setFragmentResultListener
116+
val toSave =
117+
SavedSearch(
118+
name = savedSearchName,
119+
query = bundle.getString(ARG_SEARCH_QUERY) ?: return@setFragmentResultListener,
120+
)
118121

119122
launchCatchingTask {
120-
val saveStatus = viewModel.saveSearch(savedSearchName, savedSearchQuery)
123+
val saveStatus = viewModel.saveSearch(toSave)
121124
updateAfterUserSearchIsSaved(saveStatus)
122125
}
123126
}

AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import androidx.recyclerview.widget.RecyclerView
2424
import com.ichi2.anki.CardBrowser
2525
import com.ichi2.anki.R
2626
import com.ichi2.anki.analytics.AnalyticsDialogFragment
27+
import com.ichi2.anki.browser.search.SavedSearches.SavedSearch
28+
import com.ichi2.anki.browser.search.toMap
2729
import com.ichi2.anki.databinding.CardBrowserItemMySearchesDialogBinding
2830
import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.ARG_SAVED_SEARCH
2931
import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.TYPE_SEARCH_REMOVED
@@ -146,11 +148,11 @@ class SavedBrowserSearchesDialogFragment : AnalyticsDialogFragment() {
146148
const val ARG_TYPE = "arg_type"
147149
private const val ARG_SAVED_FILTERS = "arg_saved_filters"
148150

149-
fun newInstance(savedFilters: Map<String, String>): SavedBrowserSearchesDialogFragment =
151+
fun newInstance(savedFilters: List<SavedSearch>): SavedBrowserSearchesDialogFragment =
150152
SavedBrowserSearchesDialogFragment().apply {
151153
arguments =
152154
Bundle().also {
153-
it.putSerializable(ARG_SAVED_FILTERS, HashMap(savedFilters))
155+
it.putSerializable(ARG_SAVED_FILTERS, HashMap(savedFilters.toMap()))
154156
}
155157
}
156158
}

AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import com.ichi2.anki.browser.CardBrowserViewModel.ToggleSelectionState.SELECT_A
5858
import com.ichi2.anki.browser.CardBrowserViewModel.ToggleSelectionState.SELECT_NONE
5959
import com.ichi2.anki.browser.RepositionCardsRequest.ContainsNonNewCardsError
6060
import com.ichi2.anki.browser.RepositionCardsRequest.RepositionData
61+
import com.ichi2.anki.browser.search.SavedSearches.SavedSearch
6162
import com.ichi2.anki.export.ExportDialogFragment
6263
import com.ichi2.anki.flagCardForNote
6364
import com.ichi2.anki.libanki.Card
@@ -116,7 +117,17 @@ class CardBrowserViewModelTest : JvmTest() {
116117
}
117118
savedSearches().also { searches ->
118119
assertThat("filters after saving", searches.size, equalTo(1))
119-
assertThat("filters after saving", searches["hello"], equalTo("aa"))
120+
val search = searches.single()
121+
assertThat(
122+
"filters after saving",
123+
search,
124+
equalTo(
125+
SavedSearch(
126+
"hello",
127+
"aa",
128+
),
129+
),
130+
)
120131
}
121132
removeSavedSearch("hello")
122133
assertThat("filters should be empty after removing", savedSearches().size, equalTo(0))
@@ -1553,3 +1564,8 @@ private fun CardBrowserViewModelTest.moveToReviewQueue(card: Card) {
15531564
due = 50
15541565
}
15551566
}
1567+
1568+
suspend fun CardBrowserViewModel.saveSearch(
1569+
title: String,
1570+
query: String,
1571+
) = saveSearch(SavedSearch(title, query))
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.browser.search
18+
19+
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import com.ichi2.anki.browser.search.SavedSearches.SavedSearch
21+
import com.ichi2.testutils.JvmTest
22+
import org.hamcrest.MatcherAssert.assertThat
23+
import org.hamcrest.Matchers.empty
24+
import org.hamcrest.Matchers.equalTo
25+
import org.hamcrest.Matchers.hasSize
26+
import org.junit.Assert.assertFalse
27+
import org.junit.Assert.assertTrue
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
31+
@RunWith(AndroidJUnit4::class)
32+
class SavedSearchesTest : JvmTest() {
33+
@Test
34+
fun `matches Anki Desktop ordering`() =
35+
withSavedSearches {
36+
add("red")
37+
add("blue")
38+
val (_, result) = add("green")
39+
40+
assertThat(result.map { it.name }, equalTo(listOf("blue", "green", "red")))
41+
}
42+
43+
@Test
44+
fun `searches are case sensitive`() =
45+
withSavedSearches {
46+
add("a")
47+
add("A")
48+
val (_, result) = add("Z")
49+
50+
assertThat(result.map { it.name }, equalTo(listOf("A", "Z", "a")))
51+
}
52+
53+
@Test
54+
fun `add fails on name clash`() =
55+
withSavedSearches {
56+
add(SavedSearch("a", "b")).also { (success, values) ->
57+
assertTrue(success)
58+
assertThat(values, hasSize(1))
59+
assertThat(values.single(), equalTo(SavedSearch("a", "b")))
60+
}
61+
62+
// success: false; no change in values
63+
add(SavedSearch("a", "c")).also { (success, values) ->
64+
assertFalse(success)
65+
assertThat(values, hasSize(1))
66+
assertThat(values.single(), equalTo(SavedSearch("a", "b")))
67+
}
68+
}
69+
70+
@Test
71+
fun `remove by name - found`() =
72+
withSavedSearches {
73+
add("a")
74+
add("b")
75+
val (success, values) = removeByName("a")
76+
assertTrue(success)
77+
assertThat(values, hasSize(1))
78+
assertThat(values.single(), equalTo(SavedSearch("b", "b")))
79+
}
80+
81+
@Test
82+
fun `remove by name - missing`() =
83+
withSavedSearches {
84+
val (success, _) = removeByName("not found")
85+
assertFalse(success)
86+
}
87+
88+
@Test
89+
fun `test clear`() =
90+
withSavedSearches {
91+
clear()
92+
add("a")
93+
add("b")
94+
clear()
95+
assertThat(loadFromConfig(), empty())
96+
}
97+
98+
private fun withSavedSearches(block: suspend SavedSearches.() -> Unit) =
99+
runTest {
100+
block(SavedSearches)
101+
}
102+
}
103+
104+
context(_: SavedSearches)
105+
suspend fun add(data: String) = SavedSearches.add(SavedSearch(data, data))

0 commit comments

Comments
 (0)