diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/AdvancedSearchFieldsTab.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/AdvancedSearchFieldsTab.kt new file mode 100644 index 000000000000..38f56aa8de5d --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/AdvancedSearchFieldsTab.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.browser.search + +import anki.search.SearchNode +import anki.search.SearchNodeKt.field +import anki.search.copy +import anki.search.searchNode +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.common.annotations.NeedsTest +import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.Field +import com.ichi2.anki.model.ResultType +import com.ichi2.anki.utils.ext.buildSearchString + +/** + * The **Fields** tab of [AdvancedSearchFragment] + * + * See: https://docs.ankiweb.net/searching.html#limiting-to-a-field + * + * @see AdvancedSearchFragment.OptionType.SelectField + */ +@NeedsTest("'Add Reverse' with '*text*' - asterisks are inside brackets") +object AdvancedSearchFieldsTab { + /** + * Examples of advanced syntax which can be used to search for/inside a field + * + * @see FieldSearch + */ + val options: Map = + listOf( + // Unusually, searching on field content performs an exact match by default + // use *text* to match a field containing 'texting' + "field" to + object : FieldSearch("Search a named field", "field:*text*", { fieldName -> + field { + this.fieldName = fieldName + this.text = "text" // should be *text*, but broken by the backend + } + }) { + override suspend fun buildSearchString(fieldName: String): String = + buildSearchString(fieldName, appendBefore = "*", appendAfter = "*") + }, + "field_exact_match" to + FieldSearch("Search a named field (exact text match)", "field:text") { fieldName -> + field { + this.fieldName = fieldName + this.text = "text" + } + }, + "field_empty" to + FieldSearch("Notes with an empty field", "field:") { fieldName -> + field { + this.fieldName = fieldName + this.text = "" + } + }, + "field_non_empty" to + object : FieldSearch("Notes with a non-empty field", "field:_*", { fieldName -> + field { + this.fieldName = fieldName + this.text = "" // should be '_*', but broken by the backend + } + }) { + override suspend fun buildSearchString(fieldName: String): String = + super.buildSearchString(fieldName, appendBefore = "_*", appendAfter = "") + }, + "field_either_empty" to + object : FieldSearch("Notes with a field (empty or non-empty)", "field:*", { fieldName -> + field { + this.fieldName = fieldName + this.text = "" // should be '*', but broken by the backend + } + }) { + override suspend fun buildSearchString(fieldName: String): String = + super.buildSearchString(fieldName, appendBefore = "*", appendAfter = "") + }, + ).associate { ResultType(it.first) to it.second } + + /** + * Represents an advanced search which searches the content of a named [Field] + * + * ``` + * front:*dog* // any note with a front field containing the word "dog" + * front: // non-empty field + * front:* // any note type with a front field + * ``` + */ + open class FieldSearch( + val title: String, + val example: String, + // TODO: handle user-supplied text in 2 cases: + // - *text* + // - text + val buildFieldNode: (fieldName: String) -> SearchNode.Field, + ) { + open suspend fun buildSearchString(fieldName: String): String { + val searchNode = searchNode { this.field = buildFieldNode(fieldName) } + return withCol { buildSearchString(searchNode) } + } + + /** + * Optional extension to [buildSearchString], where unescaped values are required + * + * For example: if we want to apply "*text*", we want all but the asterisks to be escaped + * by [Collection.buildSearchString] + */ + protected suspend fun buildSearchString( + fieldName: String, + appendBefore: String = "", + appendAfter: String = "", + ): String { + val beforePlaceholder = "!!BEFORE!!" + val afterPlaceholder = "!!AFTER!!" + + val fieldNode = buildFieldNode(fieldName) + val updatedFieldName = + fieldNode.copy { + text = "$beforePlaceholder${fieldNode.text}$afterPlaceholder" + } + val searchNode = searchNode { this.field = updatedFieldName } + val searchString = withCol { buildSearchString(searchNode) } + return searchString + .replace(beforePlaceholder, appendBefore) + .replace(afterPlaceholder, appendAfter) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/AdvancedSearchFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/AdvancedSearchFragment.kt index 9c4825e910f7..979d9488b33b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/AdvancedSearchFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/AdvancedSearchFragment.kt @@ -30,10 +30,18 @@ import androidx.viewpager2.adapter.FragmentStateAdapter import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.ichi2.anki.R -import com.ichi2.anki.browser.CardBrowserFragmentViewModel +import com.ichi2.anki.browser.search.AdvancedSearchFieldsTab.FieldSearch +import com.ichi2.anki.browser.search.AdvancedSearchFragment.OptionData +import com.ichi2.anki.browser.search.AdvancedSearchFragment.OptionType +import com.ichi2.anki.browser.search.AdvancedSearchFragment.OptionType.InsertExample import com.ichi2.anki.databinding.DialogGenericRecyclerViewBinding import com.ichi2.anki.databinding.FragmentAdvancedSearchBinding import com.ichi2.anki.databinding.ViewAdvancedSearchListItemBinding +import com.ichi2.anki.dialogs.FieldSelectionDialog +import com.ichi2.anki.dialogs.FieldSelectionDialog.Companion.registerFieldSelectionHandler +import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.model.ResultType +import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.openUrl import dev.androidbroadcast.vbpd.viewBinding import kotlinx.parcelize.Parcelize @@ -55,11 +63,25 @@ class AdvancedSearchFragment : Fragment(R.layout.fragment_advanced_search) { @Suppress("unused") private val viewModel: CardBrowserSearchViewModel by activityViewModels() + @Parcelize + sealed class OptionType : Parcelable { + data class InsertExample( + val example: String, + ) : OptionType() + + data class SelectField( + val resultType: ResultType, + ) : OptionType() + } + @Parcelize data class OptionData( val title: String, val example: String, - ) : Parcelable + val type: OptionType, + ) : Parcelable { + constructor(title: String, example: String) : this(title, example, InsertExample(example)) + } data class TabData( val title: String, @@ -70,15 +92,10 @@ class AdvancedSearchFragment : Fragment(R.layout.fragment_advanced_search) { listOf( TabData( "Fields", - listOf( - OptionData("Search a named field", "field:*text*"), - OptionData("Search a named field (exact text match)", "field:text"), - OptionData("Search a field with a space in the name", "\"a field:text\""), - OptionData("Notes with an empty field", "field:"), - OptionData("Notes with a non-empty field", "field:_*"), - OptionData("Notes with a field (empty or non-empty)", "field:*"), - OptionData("Search multiple fields", "fi*ld:text"), - ), + AdvancedSearchFieldsTab.options.toOptionData() + + listOf( + OptionData("Search multiple fields", "fi*ld:text"), + ), ), // TODO: Find tag without subtags? TabData( @@ -199,6 +216,33 @@ class AdvancedSearchFragment : Fragment(R.layout.fragment_advanced_search) { ), ) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setupFieldRequestListeners() + } + + /** + * Sets up listeners for [FieldSelectionDialog] + * + * @see AdvancedSearchFieldsTab + * @see registerFieldSelectionHandler + */ + private fun setupFieldRequestListeners() { + childFragmentManager.registerFieldSelectionHandler { resultType, fieldName -> + val fieldSearch = + AdvancedSearchFieldsTab.options[resultType] ?: run { + Timber.w("resultType '%s' was unhandled", resultType) + showSnackbar(R.string.something_wrong) + return@registerFieldSelectionHandler + } + launchCatchingTask { + val searchString = fieldSearch.buildSearchString(fieldName) + viewModel.appendAdvancedSearch(searchString) + } + } + } + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -228,7 +272,7 @@ class AdvancedSearchFragment : Fragment(R.layout.fragment_advanced_search) { /** * Displays a list of Advanced Search items with a name and an example * - * Selecting a list item results in a call to [CardBrowserFragmentViewModel.appendAdvancedSearch] + * Selecting a list item results in a call to [CardBrowserSearchViewModel.appendAdvancedSearch] * * @see OptionData */ @@ -245,6 +289,17 @@ class AdvancedSearchFragment : Fragment(R.layout.fragment_advanced_search) { ), ) + fun onOptionSelected(optionData: OptionData) { + when (optionData.type) { + is InsertExample -> viewModel.appendAdvancedSearch(optionData.example) + is OptionType.SelectField -> + launchCatchingTask { + val dialog = FieldSelectionDialog.createInstance(optionData.type.resultType) + dialog.show(parentFragmentManager, FieldSelectionDialog.TAG) + } + } + } + private val viewModel: CardBrowserSearchViewModel by activityViewModels() override fun onViewCreated( @@ -277,7 +332,7 @@ class AdvancedSearchFragment : Fragment(R.layout.fragment_advanced_search) { holder.binding.sample.text = data[position].example holder.binding.root.setOnClickListener { - viewModel.appendAdvancedSearch(data[position].example) + onOptionSelected(data[position]) } } @@ -306,3 +361,12 @@ class AdvancedSearchFragment : Fragment(R.layout.fragment_advanced_search) { const val TAG = "ADVANCED" } } + +private fun Map.toOptionData(): List = + this.map { + OptionData( + title = it.value.title, + example = it.value.example, + type = OptionType.SelectField(it.key), + ) + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FieldSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FieldSelectionDialog.kt new file mode 100644 index 000000000000..a05b821de5be --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FieldSelectionDialog.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.dialogs + +import android.app.Dialog +import android.os.Bundle +import androidx.core.os.BundleCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.dialogs.FieldSelectionDialog.Companion.RESULT_TYPE +import com.ichi2.anki.dialogs.FieldSelectionDialog.Companion.registerFieldSelectionHandler +import com.ichi2.anki.libanki.Field +import com.ichi2.anki.model.ResultType +import com.ichi2.anki.utils.ext.requireParcelable +import com.ichi2.anki.utils.ext.requireString +import com.ichi2.utils.create +import timber.log.Timber + +/** + * A dialog to display all [fields][Field] in the collection for selection + * + * Use [registerFieldSelectionHandler] to handle results from this class + * + * @see InsertFieldDialog for selecting fields for use in the card template editor + */ +// TODO: Support searching +// TODO: Support looking up via note type. +class FieldSelectionDialog : DialogFragment() { + val fieldNames + get() = + requireNotNull(requireArguments().getStringArrayList(ARG_FIELD_NAMES)) { + ARG_FIELD_NAMES + } + + val resultType get() = + requireNotNull(BundleCompat.getParcelable(requireArguments(), RESULT_TYPE, ResultType::class.java)) { + RESULT_TYPE + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + MaterialAlertDialogBuilder(requireContext()).create { + setTitle(TR.notetypesFields()) + setItems(fieldNames.toTypedArray()) { _, which -> + onFieldSelected(fieldNames[which]) + } + } + + private fun onFieldSelected(fieldName: String) { + parentFragmentManager.setFragmentResult( + REQUEST_KEY, + Bundle().apply { + putParcelable(RESULT_TYPE, resultType) + putString(RESULT_SELECTED_FIELD_NAME, fieldName) + }, + ) + } + + companion object { + const val TAG = "FieldSelectionDialog" + private const val ARG_FIELD_NAMES = "fieldNames" + private const val REQUEST_KEY = "requestKey" + + /** Result key for the Fragment Result API. Name of the selected field */ + private const val RESULT_SELECTED_FIELD_NAME = "selectedFieldName" + + /** + * Returned in the Fragment Result API bundle. + * + * User-supplied value to differentiate between different instances of [FieldSelectionDialog] + * + * Default value: `""` + */ + private const val RESULT_TYPE = "resultType" + + /** + * @param resultType optional result string returned in the Fragment Result API bundle under + * the [RESULT_TYPE] key + */ + suspend fun createInstance(resultType: ResultType = ResultType("")): FieldSelectionDialog { + val fieldNames = + withCol { + notetypes + .all() + .flatMap { nt -> nt.fields.map { fld -> fld.name } } + }.distinct() + + val bundle = + Bundle().apply { + putParcelable(RESULT_TYPE, resultType) + putStringArrayList(ARG_FIELD_NAMES, ArrayList(fieldNames)) + } + + Timber.i("Creating $TAG for resultType: %s", resultType) + return FieldSelectionDialog().apply { arguments = bundle } + } + + /** + * Registers a fragment result listener to handle a field selection + * + * @param action a lambda with the type of action and the name of the field + * + * @see FieldSelectionDialog + */ + context(lifecycleOwner: LifecycleOwner) + fun FragmentManager.registerFieldSelectionHandler(action: (ResultType, String) -> Unit) { + setFragmentResultListener( + REQUEST_KEY, + lifecycleOwner, + ) { _, bundle -> + val resultType = bundle.requireParcelable(RESULT_TYPE) + val fieldName = bundle.requireString(RESULT_SELECTED_FIELD_NAME) + Timber.i("$TAG: selected %s", resultType) + Timber.d("Selected field: %s", fieldName) + action(resultType, fieldName) + } + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/ResultType.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/ResultType.kt new file mode 100644 index 000000000000..144740e0f548 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/ResultType.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.model + +import android.os.Parcelable +import androidx.fragment.app.FragmentManager +import kotlinx.parcelize.Parcelize + +/** + * Identifies the source of a bundle returned from the Fragment Result API + * + * Used to avoid registering multiple listeners with [FragmentManager.setFragmentResultListener] + * + * Pass in a [ResultType] when creating a fragment, and match on the ResultType when + * receiving the result. + * + * **Example** + * ```kotlin + * registerFieldSelectionHandler { resultType, fieldName -> + * when (resultType.value) { + * "bare_field" -> insertField("{{$fieldName}}") + * "type" -> insertField("{{type:$fieldName}}") + * } + * } + * + * FieldSelectionDialog.createInstance(ResultType("bare_field")) + * ``` + */ +@Parcelize +@JvmInline +value class ResultType( + val value: String, +) : Parcelable { + override fun toString() = value +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/BundleUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/BundleUtils.kt index 30a11f6b4d23..930521fff555 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/BundleUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/BundleUtils.kt @@ -17,6 +17,7 @@ package com.ichi2.anki.utils.ext import android.os.Bundle +import androidx.core.os.BundleCompat import androidx.core.os.bundleOf /** @@ -47,6 +48,20 @@ fun Bundle.requireLong(key: String): Long { return getLong(key) } +/** + * Retrieves a [String] value from a [Bundle] using a key, throws if not found + * + * @param key A string key + * @return the value associated with [key] + * @throws IllegalStateException If [key] does not exist in the bundle + */ +fun Bundle.requireString(key: String): String { + if (!this.containsKey(key)) { + throw IllegalStateException("key: '$key' not found") + } + return requireNotNull(getString(key)) { "String in '$key' was null" } +} + /** * Retrieves a [Int] value from a [Bundle] using a key, returns null if not found * @@ -73,6 +88,25 @@ fun Bundle.requireBoolean(key: String): Boolean { return getBoolean(key) } +/** + * Returns the value associated with the given key + + * **Note:** if the expected value is not a class provided by the Android platform, you + * must call [Bundle.setClassLoader] with the proper [ClassLoader] + * first. Otherwise, this method might throw an exception or return `null` + * + * Compatibility behavior: + * + * - SDK 34 and above, this method matches platform behavior. + * - SDK 33 and below, the object type is checked after deserialization. + */ +inline fun Bundle.requireParcelable(key: String): T { + check(containsKey(key)) { "key: '$key' not found" } + return requireNotNull(BundleCompat.getParcelable(this, key, T::class.java)) { + "Parcelable in '$key' was null" + } +} + /** * Returns a new [Bundle] with the given key/value pairs as elements. * diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Collection.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Collection.kt index 0d6dbe6142d4..7ee7906c91f9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Collection.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Collection.kt @@ -18,6 +18,7 @@ package com.ichi2.anki.utils.ext import androidx.annotation.CheckResult import anki.collection.OpChangesWithCount import anki.config.ConfigKey +import anki.search.SearchNode import com.ichi2.anki.Flag import com.ichi2.anki.libanki.Collection @@ -36,3 +37,10 @@ var Collection.cardStateCustomizer: String set(value) { config.setString(ConfigKey.String.CARD_STATE_CUSTOMIZER, value) } + +/** + * Constructs a search string from a [SearchNode]. + * + * @see Collection.buildSearchString + */ +fun Collection.buildSearchString(node: SearchNode): String = buildSearchString(listOf(node))