Skip to content

Commit

Permalink
feat: Enable developer option for "Find and replace" browser dialog
Browse files Browse the repository at this point in the history
Added as a developer option for now.

Provides a dialog that follows the desktop ui to allow the user to
bulk change their notes. Ui follows the desktop code:

- offers all options that desktop offers
- shows feedback when done with the count of notes changed
- the operation is undoable(from DeckPicker)

If enabled, this option will always(no selection/multi select mode) be present
in the menu.
  • Loading branch information
lukstbit committed Feb 14, 2025
1 parent 142244b commit 3e52c07
Show file tree
Hide file tree
Showing 11 changed files with 793 additions and 3 deletions.
60 changes: 60 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,6 +67,16 @@ 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.FindAndReplaceDialogFragment.Companion.ALL_FIELDS_AS_FIELD
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.browser.FindAndReplaceDialogFragment.Companion.TAGS_AS_FIELD
import com.ichi2.anki.browser.PreviewerIdsFile
import com.ichi2.anki.browser.RepositionCardFragment
import com.ichi2.anki.browser.RepositionCardFragment.Companion.REQUEST_REPOSITION_NEW_CARDS
Expand Down Expand Up @@ -99,6 +109,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 +467,37 @@ open class CardBrowser :
shift = bundle.getBoolean(RepositionCardFragment.ARG_SHIFT),
)
}

supportFragmentManager.setFragmentResultListener(REQUEST_FIND_AND_REPLACE, this) { _, bundle ->
launchCatchingTask {
val targetField = bundle.getString(ARG_FIELD) ?: return@launchCatchingTask
val search = bundle.getString(ARG_SEARCH) ?: return@launchCatchingTask
val replacement = bundle.getString(ARG_REPLACEMENT) ?: return@launchCatchingTask
val onlyOnSelectedNotes = bundle.getBoolean(ARG_ONLY_SELECTED_NOTES, true)
val matchCase = bundle.getBoolean(ARG_MATCH_CASE, false)
val regex = bundle.getBoolean(ARG_REGEX, false)
withProgress {
// 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 (onlyOnSelectedNotes) viewModel.queryAllSelectedNoteIds() else emptyList()

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

override fun setupBackPressedCallbacks() {
Expand Down Expand Up @@ -685,6 +727,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 +1014,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 +1287,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 +1322,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
@@ -0,0 +1,196 @@
/****************************************************************************************
* Copyright (c) 2025 lukstbit <[email protected]> *
* *
* 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.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 =
mutableListOf<String>().apply {
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"
}
}
Loading

0 comments on commit 3e52c07

Please sign in to comment.