Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: Add developer option for find and replace #17897

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
33 changes: 33 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,15 @@ import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Initializing
import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Searching
import com.ichi2.anki.browser.CardOrNoteId
import com.ichi2.anki.browser.ColumnHeading
import com.ichi2.anki.browser.FindAndReplaceDialogFragment
import com.ichi2.anki.browser.PreviewerIdsFile
import com.ichi2.anki.browser.RepositionCardFragment
import com.ichi2.anki.browser.RepositionCardFragment.Companion.REQUEST_REPOSITION_NEW_CARDS
import com.ichi2.anki.browser.RepositionCardsRequest.ContainsNonNewCardsError
import com.ichi2.anki.browser.RepositionCardsRequest.RepositionData
import com.ichi2.anki.browser.SaveSearchResult
import com.ichi2.anki.browser.SharedPreferencesLastDeckIdRepository
import com.ichi2.anki.browser.registerFindReplaceHandler
import com.ichi2.anki.browser.toCardBrowserLaunchOptions
import com.ichi2.anki.dialogs.BrowserOptionsDialog
import com.ichi2.anki.dialogs.CardBrowserMySearchesDialog
Expand All @@ -99,6 +101,7 @@ import com.ichi2.anki.model.CardsOrNotes.CARDS
import com.ichi2.anki.model.CardsOrNotes.NOTES
import com.ichi2.anki.model.SortType
import com.ichi2.anki.noteeditor.NoteEditorLauncher
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.previewer.PreviewerFragment
import com.ichi2.anki.scheduling.ForgetCardsDialog
import com.ichi2.anki.scheduling.SetDueDateDialog
Expand Down Expand Up @@ -456,6 +459,18 @@ open class CardBrowser :
shift = bundle.getBoolean(RepositionCardFragment.ARG_SHIFT),
)
}

registerFindReplaceHandler { result ->
launchCatchingTask {
withProgress {
val count =
withProgress {
viewModel.findAndReplace(result)
}.await()
showSnackbar(TR.browsingNotesUpdated(count))
}
}
}
}

override fun setupBackPressedCallbacks() {
Expand Down Expand Up @@ -685,6 +700,11 @@ open class CardBrowser :
}
}
KeyEvent.KEYCODE_F -> {
if (event.isCtrlPressed && event.isAltPressed) {
Timber.i("CTRL+ALT+F - Find and replace")
showFindAndReplaceDialog()
return true
}
if (event.isCtrlPressed) {
Timber.i("Ctrl+F - Find notes")
searchItem?.expandActionView()
Expand Down Expand Up @@ -967,6 +987,11 @@ open class CardBrowser :
actionBarMenu?.findItem(R.id.action_reschedule_cards)?.title =
TR.actionsSetDueDate().toSentenceCase(this, R.string.sentence_set_due_date)

val isFindReplaceEnabled = sharedPrefs().getBoolean(getString(R.string.pref_browser_find_replace), false)
menu.findItem(R.id.action_find_replace)?.apply {
isVisible = isFindReplaceEnabled
title = TR.browsingFindAndReplace().toSentenceCase(this@CardBrowser, R.string.sentence_find_and_replace)
}
previewItem = menu.findItem(R.id.action_preview)
onSelectionChanged()
updatePreviewMenuItem()
Expand Down Expand Up @@ -1235,6 +1260,9 @@ open class CardBrowser :
R.id.action_create_filtered_deck -> {
showCreateFilteredDeckDialog()
}
R.id.action_find_replace -> {
showFindAndReplaceDialog()
}
}
return super.onOptionsItemSelected(item)
}
Expand Down Expand Up @@ -1267,6 +1295,11 @@ open class CardBrowser :
launchCatchingTask { viewModel.searchForMarkedNotes() }
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun showFindAndReplaceDialog() {
FindAndReplaceDialogFragment().show(supportFragmentManager, FindAndReplaceDialogFragment.TAG)
}

private fun changeDisplayOrder() {
showDialogFragment(
// TODO: move this into the ViewModel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import com.ichi2.anki.CrashReportService
import com.ichi2.anki.DeckSpinnerSelection.Companion.ALL_DECKS_ID
import com.ichi2.anki.Flag
import com.ichi2.anki.PreviewerDestination
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ALL_FIELDS_AS_FIELD
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.TAGS_AS_FIELD
import com.ichi2.anki.browser.RepositionCardsRequest.RepositionData
import com.ichi2.anki.export.ExportDialogFragment.ExportType
import com.ichi2.anki.launchCatchingIO
Expand Down Expand Up @@ -1034,6 +1036,32 @@ class CardBrowserViewModel(
)
}

/**
* Replaces occurrences of search with the new value.
*
* @return the number of affected notes
* @see com.ichi2.libanki.Collection.findReplace
* @see com.ichi2.libanki.Tags.findAndReplace
*/
fun findAndReplace(result: FindReplaceResult) =
viewModelScope.async {
// TODO pass the selection as the user saw it in the dialog to avoid running "find
// and replace" on a different selection
val noteIds = if (result.onlyOnSelectedNotes) queryAllSelectedNoteIds() else emptyList()

if (result.field == TAGS_AS_FIELD) {
undoableOp {
tags.findAndReplace(noteIds, result.search, result.replacement, result.regex, result.matchCase)
}.count
} else {
val field =
if (result.field == ALL_FIELDS_AS_FIELD) null else result.field
undoableOp {
findReplace(noteIds, result.search, result.replacement, result.regex, field, result.matchCase)
}.count
}
}

companion object {
fun factory(
lastDeckIdRepository: LastDeckIdRepository,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/****************************************************************************************
* Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.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

import android.app.Dialog
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.CheckBox
import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.constraintlayout.widget.Group
import androidx.core.os.bundleOf
import androidx.core.text.HtmlCompat
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.lifecycleScope
import com.ichi2.anki.CardBrowser
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.R
import com.ichi2.anki.analytics.AnalyticsDialogFragment
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_FIELD
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_MATCH_CASE
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_ONLY_SELECTED_NOTES
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REGEX
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REPLACEMENT
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_SEARCH
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.REQUEST_FIND_AND_REPLACE
import com.ichi2.anki.notetype.ManageNotetypes
import com.ichi2.anki.ui.internationalization.toSentenceCase
import com.ichi2.anki.utils.openUrl
import com.ichi2.utils.customView
import com.ichi2.utils.negativeButton
import com.ichi2.utils.neutralButton
import com.ichi2.utils.positiveButton
import com.ichi2.utils.show
import com.ichi2.utils.title
import kotlinx.coroutines.launch
import timber.log.Timber

/**
* Dialog that shows the options for finding and replacing the text of notes in [CardBrowser].
*
* Note for completeness:
*
* Desktop also shows the fields of a note in the browser and the user can right-click on one of
* them to start a find and replace only for that field. We display the fields only in
* [ManageNotetypes] which doesn't feel like it should have this feature.
* (see https://github.com/ankitects/anki/blob/64ca90934bc26ddf7125913abc9dd9de8cb30c2b/qt/aqt/browser/sidebar/tree.py#L1074)
*/
// TODO desktop offers history for inputs
class FindAndReplaceDialogFragment : AnalyticsDialogFragment() {
private val browserViewModel by activityViewModels<CardBrowserViewModel>()
private val fieldSelector: Spinner?
get() = dialog?.findViewById(R.id.fields_selector)
private val onlySelectedNotes: CheckBox?
get() = dialog?.findViewById(R.id.check_only_selected_notes)
private val contentViewsGroup: Group?
get() = dialog?.findViewById(R.id.content_views_group)
private val loadingViewsGroup: Group?
get() = dialog?.findViewById(R.id.loading_views_group)

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val contentView = layoutInflater.inflate(R.layout.fragment_find_replace, null)
contentView.setupLabels()
val title =
TR
.browsingFindAndReplace()
.toSentenceCase(requireContext(), R.string.sentence_find_and_replace)
return AlertDialog
.Builder(requireContext())
.show {
title(text = title)
customView(contentView)
neutralButton(R.string.help) { openUrl(R.string.link_manual_browser_find_replace) }
negativeButton(R.string.dialog_cancel)
positiveButton(R.string.dialog_ok) { startFindReplace() }
}.also { dialog ->
dialog.positiveButton.isEnabled = false
}
}

private fun View.setupLabels() {
findViewById<TextView>(R.id.label_find).text =
HtmlCompat.fromHtml(TR.browsingFind(), HtmlCompat.FROM_HTML_MODE_LEGACY)
findViewById<TextView>(R.id.label_replace).text =
HtmlCompat.fromHtml(TR.browsingReplaceWith(), HtmlCompat.FROM_HTML_MODE_LEGACY)
findViewById<TextView>(R.id.label_in).text =
HtmlCompat.fromHtml(TR.browsingIn(), HtmlCompat.FROM_HTML_MODE_LEGACY)
findViewById<CheckBox>(R.id.check_only_selected_notes).text = TR.browsingSelectedNotesOnly()
findViewById<CheckBox>(R.id.check_ignore_case).text = TR.browsingIgnoreCase()
findViewById<CheckBox>(R.id.check_input_as_regex).text =
TR.browsingTreatInputAsRegularExpression()
}

override fun onStart() {
super.onStart()
lifecycleScope.launch {
(dialog as? AlertDialog)?.positiveButton?.isEnabled = false
contentViewsGroup?.isVisible = false
loadingViewsGroup?.isVisible = true
val noteIds = browserViewModel.queryAllSelectedNoteIds()
onlySelectedNotes?.isChecked = noteIds.isNotEmpty()
onlySelectedNotes?.isEnabled = noteIds.isNotEmpty()
val fieldsNames =
buildList {
add(
TR.browsingAllFields().toSentenceCase(
this@FindAndReplaceDialogFragment,
R.string.sentence_all_fields,
),
)
add(TR.editingTags())
addAll(withCol { fieldNamesForNoteIds(noteIds) })
}
fieldSelector?.adapter =
ArrayAdapter(
requireActivity(),
android.R.layout.simple_spinner_item,
fieldsNames,
).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
loadingViewsGroup?.isVisible = false
contentViewsGroup?.isVisible = true
(dialog as? AlertDialog)?.positiveButton?.isEnabled = true
}
}

// https://github.com/ankitects/anki/blob/64ca90934bc26ddf7125913abc9dd9de8cb30c2b/qt/aqt/browser/find_and_replace.py#L118
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun startFindReplace() {
val search = dialog?.findViewById<EditText>(R.id.input_search)?.text
val replacement = dialog?.findViewById<EditText>(R.id.input_replace)?.text
if (search.isNullOrEmpty() || replacement == null) return
val onlyInSelectedNotes = onlySelectedNotes?.isChecked ?: true
val ignoreCase =
dialog?.findViewById<CheckBox>(R.id.check_ignore_case)?.isChecked ?: true
val inputAsRegex =
dialog?.findViewById<CheckBox>(R.id.check_input_as_regex)?.isChecked ?: false
val selectedField =
when (fieldSelector?.selectedItemPosition ?: AdapterView.INVALID_POSITION) {
AdapterView.INVALID_POSITION -> return
0 -> ALL_FIELDS_AS_FIELD
1 -> TAGS_AS_FIELD
else -> fieldSelector?.selectedItem as? String ?: return
}
Timber.i("Sending request to find and replace...")
setFragmentResult(
REQUEST_FIND_AND_REPLACE,
bundleOf(
ARG_SEARCH to search.toString(),
ARG_REPLACEMENT to replacement.toString(),
ARG_FIELD to selectedField,
ARG_ONLY_SELECTED_NOTES to onlyInSelectedNotes,
// "Ignore case" checkbox text => when it's checked we pass false to the backend
ARG_MATCH_CASE to !ignoreCase,
ARG_REGEX to inputAsRegex,
),
)
}

companion object {
const val TAG = "FindAndReplaceDialogFragment"
const val REQUEST_FIND_AND_REPLACE = "request_find_and_replace"
const val ARG_SEARCH = "arg_search"
const val ARG_REPLACEMENT = "arg_replacement"
const val ARG_FIELD = "arg_field"
const val ARG_ONLY_SELECTED_NOTES = "arg_only_selected_notes"
const val ARG_MATCH_CASE = "arg_match_case"
const val ARG_REGEX = "arg_regex"

/**
* Receiving this value in the result [Bundle] for the [ARG_FIELD] entry means that
* the user selected "All fields" as the field target for the find and replace action.
*/
const val ALL_FIELDS_AS_FIELD = "find_and_replace_dialog_fragment_all_fields_as_field"

/**
* Receiving this value in the result [Bundle] for the [ARG_FIELD] entry means that
* the user selected "Tags" as the field target for the find and replace action.
*/
const val TAGS_AS_FIELD = "find_and_replace_dialog_fragment_tags_as_field"
}
}

fun CardBrowser.registerFindReplaceHandler(action: (FindReplaceResult) -> Unit) {
supportFragmentManager.setFragmentResultListener(REQUEST_FIND_AND_REPLACE, this) { _, bundle ->
action(
FindReplaceResult(
search = bundle.getString(ARG_SEARCH) ?: error("Missing required argument: search"),
replacement =
bundle.getString(ARG_REPLACEMENT) ?: error("Missing required argument: replacement"),
field = bundle.getString(ARG_FIELD) ?: error("Missing required argument: field"),
bundle.getBoolean(ARG_ONLY_SELECTED_NOTES, true),
bundle.getBoolean(ARG_MATCH_CASE, false),
bundle.getBoolean(ARG_REGEX, false),
),
)
}
}

data class FindReplaceResult(
val search: String,
val replacement: String,
val field: String,
val onlyOnSelectedNotes: Boolean,
val matchCase: Boolean,
val regex: Boolean,
)
17 changes: 11 additions & 6 deletions AnkiDroid/src/main/java/com/ichi2/libanki/Collection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ import com.ichi2.libanki.utils.NotInLibAnki
import com.ichi2.libanki.utils.TimeManager
import com.ichi2.utils.KotlinCleanup
import net.ankiweb.rsdroid.Backend
import net.ankiweb.rsdroid.RustCleanup
import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException
import timber.log.Timber
import java.io.File
Expand Down Expand Up @@ -396,16 +395,22 @@ class Collection(
return noteIDsList
}

/**
* @return An [OpChangesWithCount] representing the number of affected notes
*/
@LibAnkiAlias("find_and_replace")
@RustCleanup("Calling code should handle returned OpChanges")
@CheckResult
fun findReplace(
nids: List<Long>,
src: String,
dst: String,
search: String,
replacement: String,
regex: Boolean = false,
field: String? = null,
fold: Boolean = true,
): Int = backend.findAndReplace(nids, src, dst, regex, !fold, field ?: "").count
matchCase: Boolean = false,
): OpChangesWithCount = backend.findAndReplace(nids, search, replacement, regex, matchCase, field ?: "")

@LibAnkiAlias("field_names_for_note_ids")
fun fieldNamesForNoteIds(nids: List<Long>): List<String> = backend.fieldNamesForNotes(nids)

// Browser Table

Expand Down
Loading
Loading