Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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<ResultType, FieldSearch> =
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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?,
Expand Down Expand Up @@ -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
*/
Expand All @@ -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(
Expand Down Expand Up @@ -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])
}
}

Expand Down Expand Up @@ -306,3 +361,12 @@ class AdvancedSearchFragment : Fragment(R.layout.fragment_advanced_search) {
const val TAG = "ADVANCED"
}
}

private fun Map<ResultType, FieldSearch>.toOptionData(): List<OptionData> =
this.map {
OptionData(
title = it.value.title,
example = it.value.example,
type = OptionType.SelectField(it.key),
)
}
Loading
Loading