From 047acd47e09240c17999d2076e4f8cbea177f986 Mon Sep 17 00:00:00 2001 From: Sumit Singh Date: Sat, 7 Mar 2026 02:07:30 +0530 Subject: [PATCH] fix: preserve tag segments when normalizing search for ignore-accents --- .../anki/browser/CardBrowserViewModel.kt | 4 ++-- .../java/com/ichi2/anki/utils/ext/String.kt | 23 +++++++++++++++++++ .../anki/browser/CardBrowserViewModelTest.kt | 18 +++++++++++++++ .../anki/browser/StringNormalizationTest.kt | 15 ++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index f0d651793572..274ed15b155f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -77,7 +77,7 @@ import com.ichi2.anki.preferences.SharedPreferencesProvider import com.ichi2.anki.settings.Prefs import com.ichi2.anki.settings.PrefsRepository import com.ichi2.anki.utils.ext.currentCardBrowse -import com.ichi2.anki.utils.ext.normalizeForSearch +import com.ichi2.anki.utils.ext.normalizeForSearchPreservingTags import com.ichi2.anki.utils.ext.setUserFlagForCards import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job @@ -1178,7 +1178,7 @@ class CardBrowserViewModel( } flowOfSearchTerms.value = if (shouldIgnoreAccents) { - searchQuery.normalizeForSearch() + searchQuery.normalizeForSearchPreservingTags() } else { searchQuery } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/String.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/String.kt index 2791c82e082a..50649d498e94 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/String.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/String.kt @@ -22,6 +22,9 @@ import java.util.regex.Pattern private val DIACRITICAL_MARKS_PATTERN = Pattern.compile("\\p{InCombiningDiacriticalMarks}+") +/** Matches `tag:` + quoted or unquoted value. Left verbatim so backend matches tags exactly. */ +private val TAG_SEGMENT_PATTERN = Pattern.compile("tag:(?:\"[^\"]*\"|[^\\s\\)]+)") + /** * Normalizes the given string by removing diacritical marks (accents) to enable accent-insensitive searches. * @@ -42,3 +45,23 @@ fun String.normalizeForSearch(): String { val normalized = Normalizer.normalize(this, Normalizer.Form.NFD) return DIACRITICAL_MARKS_PATTERN.matcher(normalized).replaceAll("") } + +/** + * Normalizes query for accent-insensitive search but keeps `tag:...` segments unchanged + * (backend matches tags literally). Uses [normalizeForSearch] only outside tag segments. + * + * @receiver Raw search query (e.g. `café ("tag:être")`). + * @return Query with accents removed except inside `tag:...`. + */ +fun String.normalizeForSearchPreservingTags(): String { + val matcher = TAG_SEGMENT_PATTERN.matcher(this) + val sb = StringBuilder(length) + var cursor = 0 + while (matcher.find()) { + sb.append(substring(cursor, matcher.start()).normalizeForSearch()) + sb.append(matcher.group()) + cursor = matcher.end() + } + sb.append(substring(cursor).normalizeForSearch()) + return sb.toString() +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt index d62c795bb07f..f200b9976005 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -19,11 +19,13 @@ package com.ichi2.anki.browser import androidx.core.content.edit import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 +import anki.config.ConfigKey import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.CardBrowser import com.ichi2.anki.CollectionManager +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.Flag import com.ichi2.anki.NoteEditorActivity import com.ichi2.anki.NoteEditorFragment @@ -70,6 +72,7 @@ import com.ichi2.anki.libanki.QueueType import com.ichi2.anki.libanki.QueueType.ManuallyBuried import com.ichi2.anki.libanki.QueueType.New import com.ichi2.anki.libanki.testutils.AnkiTest +import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.model.CardsOrNotes import com.ichi2.anki.model.SelectableDeck import com.ichi2.anki.model.SortType @@ -1360,6 +1363,21 @@ class CardBrowserViewModelTest : JvmTest() { } } + @Test + fun `accented tags are searchable if ignoring accents - Issue 20409`() = + runTest { + addBasicNote().update { tags = mutableListOf("être") } + withCol { config.setBool(ConfigKey.Bool.IGNORE_ACCENTS_IN_SEARCH, true) } + runViewModelTest(notes = 0) { + flowOfSearchState.test { + filterByTags(listOf("être"), CardStateFilter.ALL_CARDS) + assertThat(awaitItem(), equalTo(CardBrowserViewModel.SearchState.Searching)) + assertThat(awaitItem(), equalTo(CardBrowserViewModel.SearchState.Completed)) + assertThat(rowCount, equalTo(1)) + } + } + } + private fun assertDate(str: String?) { // 2025-01-09 @ 18:06 assertNotNull(str) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/StringNormalizationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/StringNormalizationTest.kt index a134684cdf0d..d5c2e8def836 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/StringNormalizationTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/StringNormalizationTest.kt @@ -18,6 +18,7 @@ package com.ichi2.anki.browser import com.ichi2.anki.utils.ext.normalizeForSearch +import com.ichi2.anki.utils.ext.normalizeForSearchPreservingTags import org.junit.Assert.assertEquals import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource @@ -37,4 +38,18 @@ class StringNormalizationTest { ) { assertEquals(expected, input.normalizeForSearch()) } + + @ParameterizedTest + @CsvSource( + "café tag:foo, cafe tag:foo", + "tag:être, tag:être", + "(\"tag:être\" or \"tag:foo\"), (\"tag:être\" or \"tag:foo\")", + "résumé tag:naïve, resume tag:naïve", + ) + fun `test normalizeForSearchPreservingTags preserves tag segments`( + input: String, + expected: String, + ) { + assertEquals(expected, input.normalizeForSearchPreservingTags()) + } }