Skip to content

Commit ffc4d55

Browse files
Deals with multi part filters
Anki deck search features allows to enter multiple part of the filter. For example "math catego" allows to find the deck "category" as a subdeck of "math", and exclude "category" in any deck that don't also contains the word "math". The same search on ankidroid would only return a deck whose name contains "math catego" literally. Also, a search for "h::cat" won't show the deck math::category as ankidroid search for the string in the name of the deck not considering the parents. This commit tries to be as close as possible to anki behaviour. However, anki deck selection uses a submenu that is very different from ankidroid filtering process. So this commit tries to keep the logic of ankidroid filtering process by ensuring that if we search for "math::alge" we still shows "math::algebra" without showing "math::algebra::group" in the deck picker. A last complexity was that, when the user tap "math:", we don't want to remove the deck "math" from the search result (as it'll reappear as soon as the user has tapped "math::a", since "math" is a parent of "math::algebra" and that we show parents of shown decks). So the search feature considers in this case that "math:" can be either a part of a name of a deck, or that "math" can be the suffix of a deck's parent name. A bunch of unit tests was added to help understand the behavior of the search feature in mulitple cases. Some CharSequence were replaced by DeckFilters so that each use of the filter against a name does not require to trim and lower case the filter again. This also allows to have all the (honestly absurdly complex given the implemented behaviour) logic of the code in a class. Fixes: #20306
1 parent 40a0d4a commit ffc4d55

File tree

7 files changed

+393
-30
lines changed

7 files changed

+393
-30
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
* Copyright (c) 2026 Arthur Milchior <arthur@milchior.fr>
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.deckpicker
18+
19+
import android.annotation.SuppressLint
20+
import androidx.annotation.VisibleForTesting
21+
import java.util.Locale
22+
import kotlin.collections.filter
23+
import kotlin.sequences.filter
24+
import kotlin.text.filter
25+
26+
/**
27+
* Represents a deck search typed by the user, used to filter a list of decks.
28+
*/
29+
class DeckFilters
30+
@VisibleForTesting
31+
constructor(
32+
private val filters: List<DeckFilter>,
33+
) {
34+
/**
35+
* Whether the user is searching something
36+
*/
37+
fun searching() = filters.isNotEmpty()
38+
39+
/**
40+
* Whether all filter of [filters] appear in [name]
41+
*/
42+
@VisibleForTesting
43+
fun deckNamesMatchFilters(name: String) = filters.all { filter -> filter.deckNameMatchesFilter(name) }
44+
45+
/**
46+
* Whether at least one of the filter matches the last name.
47+
* @See [deckLastNameMatchesFilter] to understand the exact meaning
48+
*/
49+
@VisibleForTesting
50+
fun deckLastNameMatchesAFilter(name: String) = filters.any { filter -> filter.deckLastNameMatchesFilter(name) }
51+
52+
/**
53+
* Whether the deck with this full name must be kept for the current filter.
54+
*/
55+
@VisibleForTesting
56+
fun accept(name: String) =
57+
!searching() ||
58+
(
59+
deckLastNameMatchesAFilter(name) &&
60+
deckNamesMatchFilters(name)
61+
)
62+
63+
/**
64+
* Represents a single filter
65+
* @param filter: a trimmed lower case string
66+
*/
67+
class DeckFilter(
68+
private val filter: String,
69+
) {
70+
/**
71+
* Whether there is a single : at the end. This case must be treated specially in order not to remove result from the deck list
72+
* while the user starts tapping "::subdeckName"
73+
*/
74+
val endsWithSingleColumn = filter.endsWith(":") && !filter.endsWith("::")
75+
76+
/**
77+
* The filter without its last ":" if the deck name ends with exactly one ":"
78+
*/
79+
val trimmedFilter = if (endsWithSingleColumn) filter.trimEnd(':') else filter
80+
81+
/**
82+
* Whether [filter] appears in [name].
83+
*/
84+
@SuppressLint("LocaleRootUsage")
85+
fun deckNameMatchesFilter(name: String) =
86+
// If the filter is "foo:", the user may wants "foo: we can match against "foo" at the end of the deck name or with "foo:" anywhere in the deck name
87+
name.lowercase(Locale.getDefault()).contains(filter) ||
88+
name.lowercase(Locale.ROOT).contains(filter) ||
89+
name.lowercase(Locale.getDefault()).endsWith(trimmedFilter) ||
90+
name.lowercase(Locale.ROOT).endsWith(trimmedFilter)
91+
92+
/**
93+
* Whether [filter] matches against the last part of [name] specifically.
94+
* That is, if [filter] contains :: then the last suffix of the form "::foo", the name of the deck starts with "foo".
95+
* Otherwise, the name contains "foo".
96+
*/
97+
@VisibleForTesting
98+
fun deckLastNameMatchesFilter(name: String): Boolean {
99+
val indexOfSeparatorInFilter = filter.lastIndexOf("::")
100+
if (indexOfSeparatorInFilter == -1) {
101+
// "::" does not appear in the filter. Then the filter can be anywhere in
102+
// the last part of the name
103+
return deckNameMatchesFilter(name.split("::").last())
104+
}
105+
// "::" appears in the filter. Then it must be the same as the last "::" in the name.
106+
val indexOfSeparatorInName = name.lastIndexOf("::")
107+
if (indexOfSeparatorInName == -1) {
108+
// This name does not correspond to a subdeck
109+
return false
110+
}
111+
112+
// We use trimmed filter. This way:
113+
// * if the filter does not ends with a :, this is similar to the filter
114+
// * if the filter ends with a single :, we're actually considering the parent name
115+
// the deck list will contains the parent.
116+
// * If the filter ends with ::, the last deck of the filter is empty, so this last deck is not considered, instead we just check against second to last deck name
117+
return containsAtPosition(
118+
trimmedFilter,
119+
indexOfSeparatorInFilter,
120+
name,
121+
indexOfSeparatorInName,
122+
)
123+
}
124+
125+
companion object {
126+
/**
127+
* Whether [containing] contains [contained] where the positions matches.
128+
* Position must be less than the length of the string.
129+
*/
130+
@VisibleForTesting
131+
fun containsAtPosition(
132+
contained: String,
133+
positionContained: Int,
134+
containing: String,
135+
positionContaining: Int,
136+
): Boolean {
137+
val startOfContainingInContained = positionContaining - positionContained
138+
val endOfContainingInContained = startOfContainingInContained + contained.length
139+
val substringInContaining: String
140+
try {
141+
substringInContaining =
142+
containing.substring(startOfContainingInContained, endOfContainingInContained)
143+
} catch (e: IndexOutOfBoundsException) {
144+
return false
145+
}
146+
return substringInContaining.lowercase(Locale.getDefault()) ==
147+
contained.lowercase(
148+
Locale.getDefault(),
149+
) ||
150+
substringInContaining.equals(
151+
contained,
152+
ignoreCase = true,
153+
)
154+
}
155+
}
156+
}
157+
158+
companion object {
159+
/**
160+
* Returns a DeckFilters for the user input [filters]
161+
*/
162+
fun create(filters: CharSequence) =
163+
DeckFilters(
164+
filters
165+
.toString()
166+
.lowercase()
167+
.split("\\s+".toRegex())
168+
.map { it.trim() }
169+
.filter {
170+
it.isNotEmpty()
171+
}.map { DeckFilter(it) },
172+
)
173+
}
174+
}

AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ class DeckPickerViewModel :
8989
}
9090

9191
/** User filter of the deck list. Shown as a search in the UI */
92-
private val flowOfCurrentDeckFilter = MutableStateFlow("")
92+
private val flowOfCurrentDeckFilter = MutableStateFlow(DeckFilters.create(""))
9393

9494
/**
9595
* Keep track of which deck was last given focus in the deck list. If we find that this value
@@ -390,7 +390,7 @@ class DeckPickerViewModel :
390390

391391
fun updateDeckFilter(filterText: String) {
392392
Timber.d("filter: %s", filterText)
393-
flowOfCurrentDeckFilter.value = filterText
393+
flowOfCurrentDeckFilter.value = DeckFilters.create(filterText)
394394
}
395395

396396
fun toggleDeckExpand(deckId: DeckId) =

AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DisplayDeckNode.kt

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
package com.ichi2.anki.deckpicker
1919

20-
import android.annotation.SuppressLint
2120
import androidx.annotation.VisibleForTesting
2221
import com.ichi2.anki.libanki.DeckId
2322
import com.ichi2.anki.libanki.sched.DeckNode
@@ -79,33 +78,27 @@ data class DisplayDeckNode private constructor(
7978
/** Convert the tree into a flat list of [DisplayDeckNode]s, where matching decks and the children/parents
8079
* are included. Decks inside collapsed decks are not considered. */
8180
fun DeckNode.filterAndFlattenDisplay(
82-
filter: CharSequence?,
81+
filter: DeckFilters,
8382
selectedDeckId: DeckId,
8483
): List<DisplayDeckNode> {
85-
val filterPattern =
86-
if (filter.isNullOrBlank()) {
87-
null
88-
} else {
89-
filter.toString().lowercase(Locale.getDefault()).trim()
90-
}
9184
val list = mutableListOf<DisplayDeckNode>()
92-
filterAndFlattenDisplayInner(filterPattern, list, parentMatched = false, selectedDeckId)
85+
filterAndFlattenDisplayInner(filter, list, parentMatched = false, selectedDeckId)
9386
return list
9487
}
9588

9689
private fun DeckNode.filterAndFlattenDisplayInner(
97-
filter: CharSequence?,
90+
filter: DeckFilters,
9891
list: MutableList<DisplayDeckNode>,
9992
parentMatched: Boolean,
10093
selectedDeckId: DeckId,
10194
) {
102-
if (!isSyntheticDeck && (nameMatchesFilter((filter)) || parentMatched)) {
95+
if (!isSyntheticDeck && (filter.accept(fullDeckName) || parentMatched)) {
10396
this.addVisibleToList(list, matchesSearchOrChild = true, selectedDeckId)
10497
return
10598
}
10699

107100
// When searching, ignore collapsed state and always search children
108-
val searching = filter != null
101+
val searching = filter.searching()
109102
if (collapsed && !searching) {
110103
return
111104
}
@@ -151,12 +144,3 @@ fun DeckNode.addVisibleToList(list: MutableList<DeckNode>) {
151144
}
152145
}
153146
}
154-
155-
@SuppressLint("LocaleRootUsage")
156-
private fun DeckNode.nameMatchesFilter(filter: CharSequence?): Boolean {
157-
return if (filter == null) {
158-
true
159-
} else {
160-
return node.name.lowercase(Locale.getDefault()).contains(filter) || node.name.lowercase(Locale.ROOT).contains(filter)
161-
}
162-
}

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import com.ichi2.anki.common.annotations.NeedsTest
4444
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
4545
import com.ichi2.anki.databinding.DeckPickerDialogListItemBinding
4646
import com.ichi2.anki.databinding.DialogDeckPickerBinding
47+
import com.ichi2.anki.deckpicker.DeckFilters
4748
import com.ichi2.anki.dialogs.DeckSelectionDialog.DecksArrayAdapter.DecksFilter
4849
import com.ichi2.anki.launchCatchingTask
4950
import com.ichi2.anki.libanki.DeckId
@@ -376,13 +377,16 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() {
376377
override fun getFilter(): Filter = DecksFilter()
377378

378379
private inner class DecksFilter : TypedFilter<DeckNode>(allDecksList) {
380+
/**
381+
* Returns all the deck nodes of [items] that contains every pattern of the constraints.
382+
* In the constraints, patterns are separated by any whitespace character.
383+
*/
379384
override fun filterResults(
380385
constraint: CharSequence,
381386
items: List<DeckNode>,
382-
): List<DeckNode> {
383-
val filterPattern = constraint.toString().lowercase(Locale.getDefault()).trim()
384-
return items.filter {
385-
it.fullDeckName.lowercase(Locale.getDefault()).contains(filterPattern)
387+
) = DeckFilters.create(constraint).let { deckFilters ->
388+
items.filter { node ->
389+
deckFilters.accept(node.fullDeckName)
386390
}
387391
}
388392

AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@
1616

1717
package com.ichi2.anki.utils.ext
1818

19+
import android.R.attr.name
20+
import android.annotation.SuppressLint
21+
import androidx.annotation.VisibleForTesting
1922
import com.ichi2.anki.libanki.Deck
2023
import com.ichi2.anki.libanki.DeckId
2124
import com.ichi2.anki.libanki.Decks
25+
import com.ichi2.anki.libanki.sched.DeckNode
26+
import java.util.Locale
27+
import kotlin.collections.any
2228

2329
fun Decks.update(
2430
did: DeckId,

0 commit comments

Comments
 (0)