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
Expand Up @@ -82,6 +82,7 @@ import com.ichi2.anki.dialogs.tags.TagsDialog
import com.ichi2.anki.dialogs.tags.TagsDialogFactory
import com.ichi2.anki.dialogs.tags.TagsDialogListener
import com.ichi2.anki.export.ExportDialogFragment
import com.ichi2.anki.launchCatching
import com.ichi2.anki.launchCatchingTask
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.model.CardStateFilter
Expand Down Expand Up @@ -862,7 +863,13 @@ class CardBrowserFragment :

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun showFindAndReplaceDialog() {
FindAndReplaceDialogFragment().show(parentFragmentManager, FindAndReplaceDialogFragment.TAG)
lifecycleScope.launch {
withProgress {
val noteIds = activityViewModel.queryAllSelectedNoteIds()
val fragment = FindAndReplaceDialogFragment.newInstance(requireContext(), noteIds)
fragment.show(parentFragmentManager, FindAndReplaceDialogFragment.TAG)
}
}
}

@KotlinCleanup("DeckSelectionListener is almost certainly a bug - deck!!")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@
package com.ichi2.anki.browser

import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.Companion.PRIVATE
import androidx.appcompat.app.AlertDialog
import androidx.core.os.BundleCompat
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
Expand All @@ -41,6 +43,7 @@ import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REPLACE
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_SEARCH
import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.REQUEST_FIND_AND_REPLACE
import com.ichi2.anki.databinding.FragmentFindReplaceBinding
import com.ichi2.anki.libanki.NoteId
import com.ichi2.anki.notetype.ManageNotetypes
import com.ichi2.anki.ui.internationalization.toSentenceCase
import com.ichi2.anki.utils.ext.setFragmentResultListener
Expand All @@ -66,7 +69,6 @@ import timber.log.Timber
*/
// TODO desktop offers history for inputs
class FindAndReplaceDialogFragment : AnalyticsDialogFragment() {
private val browserViewModel by activityViewModels<CardBrowserViewModel>()
private lateinit var binding: FragmentFindReplaceBinding

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
Expand Down Expand Up @@ -107,7 +109,15 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() {
(dialog as? AlertDialog)?.positiveButton?.isEnabled = false
binding.contentViewsGroup.isVisible = false
binding.loadingViewsGroup.isVisible = true
val noteIds = browserViewModel.queryAllSelectedNoteIds()
val idsFile =
requireNotNull(
BundleCompat.getParcelable(
requireArguments(),
ARG_IDS,
IdsFile::class.java,
),
)
val noteIds = idsFile.getIds()
Comment on lines +112 to +120
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add handling for the file being deleted

binding.onlySelectedNotesCheckBox.isChecked = noteIds.isNotEmpty()
binding.onlySelectedNotesCheckBox.isEnabled = noteIds.isNotEmpty()
val fieldsNames =
Expand All @@ -134,7 +144,7 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() {
}

// https://github.com/ankitects/anki/blob/64ca90934bc26ddf7125913abc9dd9de8cb30c2b/qt/aqt/browser/find_and_replace.py#L118
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@VisibleForTesting(otherwise = PRIVATE)
fun startFindReplace() {
val search = binding.inputSearch.text
val replacement = binding.inputReplace.text
Expand Down Expand Up @@ -167,6 +177,7 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() {
companion object {
const val TAG = "FindAndReplaceDialogFragment"
const val REQUEST_FIND_AND_REPLACE = "request_find_and_replace"
const val ARG_IDS = " arg_ids"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const val ARG_IDS = " arg_ids"
const val ARG_IDS = "arg_ids"

const val ARG_SEARCH = "arg_search"
const val ARG_REPLACEMENT = "arg_replacement"
const val ARG_FIELD = "arg_field"
Expand All @@ -185,6 +196,16 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() {
* 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 newInstance(
context: Context,
noteIds: List<NoteId>,
): FindAndReplaceDialogFragment {
val file = IdsFile(context.cacheDir, noteIds)
Copy link
Member

@david-allison david-allison Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a common issue that IdsFile is not deleted after the activity/fragment/dialog is disposed.

Is it feasible to handle this in the PR for this fragment?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide a name to the IdsFile, this will make it easier to clean up our 'bad' IdsFile implementation at a future date

return FindAndReplaceDialogFragment().apply {
arguments = bundleOf(ARG_IDS to file)
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* 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.content.Context
import android.widget.Spinner
import android.widget.SpinnerAdapter
import androidx.core.os.bundleOf
import androidx.fragment.app.testing.FragmentScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.isNotChecked
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.libanki.Note
import com.ichi2.anki.libanki.NoteId
import com.ichi2.anki.ui.internationalization.toSentenceCase
import kotlinx.coroutines.test.advanceUntilIdle
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class FindAndReplaceDialogFragmentTest : RobolectricTest() {
@Test
fun `with no selected notes 'only selected notes' check box is not actionable`() =
runTest {
onFindReplaceFragment(emptyList()) {
// nothing selected so checkbox 'Only selected notes' should be unchecked and not enabled
onView(withId(R.id.only_selected_notes_check_box))
.inRoot(isDialog())
.check(matches(isNotEnabled()))
onView(withId(R.id.only_selected_notes_check_box))
.inRoot(isDialog())
.check(matches(isNotChecked()))
onView(withId(R.id.ignore_case_check_box))
.inRoot(isDialog())
.check(matches(isChecked()))
// using input as regex is not checked at start
onView(withId(R.id.input_as_regex_check_box))
.inRoot(isDialog())
.check(matches(isNotChecked()))
// as nothing is selected the target selector has only the two default options
assertThat(adapter.items, equalTo(targetContext.getDefaultTargets()))
}
}

@Test
fun `shows expected find-replace targets for selected notes`() =
runTest {
val n1 = createFindReplaceTestNote("A", "Car", "Lion")
val n2 = createFindReplaceTestNote("B", "Train", "Chicken")
createFindReplaceTestNote("C", "Plane", "Vulture")
onFindReplaceFragment(listOf(n1.id, n2.id)) {
// 2 default field options + 4 fields(2 from each of the two notes selected above)
// see createFindReplaceTestNote for test field names
val allTargets =
targetContext.getDefaultTargets() +
listOf("Afield0", "Afield1", "Bfield0", "Bfield1")
assertThat(adapter.items, equalTo(allTargets))
}
}

// see #19929
@Test
fun `'selected notes only' status is correct after fragment restore`() =
runTest {
val note = createFindReplaceTestNote("A", "kart", "kilogram")
val file = IdsFile(targetContext.cacheDir, listOf(note.id))
val scenario =
FragmentScenario.launch(
fragmentClass = FindAndReplaceDialogFragment::class.java,
fragmentArgs = bundleOf(FindAndReplaceDialogFragment.ARG_IDS to file),
themeResId = R.style.Theme_Light,
)
scenario.onFragment { fragment ->
advanceUntilIdle()
onView(withId(R.id.only_selected_notes_check_box))
.inRoot(isDialog())
.check(matches(isEnabled()))
onView(withId(R.id.only_selected_notes_check_box))
.inRoot(isDialog())
.check(matches(isChecked()))
// 2 default field options + 2 fields from the only note selected
val allTargets =
targetContext.getDefaultTargets() + listOf("Afield0", "Afield1")
assertThat(fragment.adapter.items, equalTo(allTargets))
}
scenario.recreate()
scenario.onFragment { fragment ->
advanceUntilIdle()
onView(withId(R.id.only_selected_notes_check_box))
.inRoot(isDialog())
.check(matches(isEnabled()))
onView(withId(R.id.only_selected_notes_check_box))
.inRoot(isDialog())
.check(matches(isChecked()))
// check that the target list from before the recreate call wasn't reset
val allTargets = targetContext.getDefaultTargets() + listOf("Afield0", "Afield1")
assertThat(fragment.adapter.items, equalTo(allTargets))
}
}

private fun onFindReplaceFragment(
noteIds: List<NoteId>,
action: FindAndReplaceDialogFragment.() -> Unit,
) {
val file = IdsFile(targetContext.cacheDir, noteIds)
FragmentScenario
.launch(
fragmentClass = FindAndReplaceDialogFragment::class.java,
fragmentArgs = bundleOf(FindAndReplaceDialogFragment.ARG_IDS to file),
themeResId = R.style.Theme_Light,
).onFragment { fragment -> fragment.action() }
}

/**
* 3 notetypes available(named A, B and C) each with two fields.
* Fields names follow the pattern: "${NotetypeName}field${0/1}" (ex: "Afield1").
* "C" notetype has the same name for field 1 as notetype B!
* second is a [Pair] representing the field data(the note has only two fields)
*/
private fun createFindReplaceTestNote(
notetypeName: String,
field0: String,
field1: String,
): Note {
addStandardNoteType("A", arrayOf("Afield0", "Afield1"), "", "")
addStandardNoteType("B", arrayOf("Bfield0", "Bfield1"), "", "")
addStandardNoteType("C", arrayOf("Cfield0", "Bfield1"), "", "")
return addNoteUsingNoteTypeName(notetypeName, field0, field1)
}

val FindAndReplaceDialogFragment.adapter: SpinnerAdapter
get() = dialog!!.findViewById<Spinner>(R.id.fields_selector).adapter as SpinnerAdapter

private val SpinnerAdapter.items: List<String>
get() =
mutableListOf<String>().apply {
for (position in 0 until count) {
add(getItem(position) as String)
}
}

private fun Context.getDefaultTargets() =
listOf(
TR.browsingAllFields().toSentenceCase(this, R.string.sentence_all_fields),
TR.editingTags(),
)
}