Skip to content

Commit aa5da3f

Browse files
david-allisonlukstbit
authored andcommitted
fix(empty-cards): undoable & correct result
* We use the backend string * We use `undoableOp` * we add 'undo' to the snackbar * We return the backend result, which may not be the supplied input - unit tested: duplicates empty_cards_deleted => TR.emptyCardsDeletedCount Fixes 16945 https://redirect.github.com/ankitects/anki/pull/3386
1 parent b79bc92 commit aa5da3f

File tree

5 files changed

+157
-12
lines changed

5 files changed

+157
-12
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ import com.ichi2.anki.deckpicker.BITMAP_BYTES_PER_PIXEL
106106
import com.ichi2.anki.deckpicker.BackgroundImage
107107
import com.ichi2.anki.deckpicker.DeckDeletionResult
108108
import com.ichi2.anki.deckpicker.DeckPickerViewModel
109+
import com.ichi2.anki.deckpicker.EmptyCardsResult
109110
import com.ichi2.anki.dialogs.AsyncDialogFragment
110111
import com.ichi2.anki.dialogs.BackupPromptDialog
111112
import com.ichi2.anki.dialogs.ConfirmationDialog
@@ -626,7 +627,14 @@ open class DeckPicker :
626627
}
627628
}
628629

630+
fun onCardsEmptied(result: EmptyCardsResult) {
631+
showSnackbar(result.toHumanReadableString(), Snackbar.LENGTH_SHORT) {
632+
setAction(R.string.undo) { undo() }
633+
}
634+
}
635+
629636
viewModel.deckDeletedNotification.launchCollectionInLifecycleScope(::onDeckDeleted)
637+
viewModel.emptyCardsNotification.launchCollectionInLifecycleScope(::onCardsEmptied)
630638
}
631639

632640
private val onReceiveContentListener =
@@ -2487,26 +2495,23 @@ open class DeckPicker :
24872495

24882496
private fun handleEmptyCards() {
24892497
launchCatchingTask {
2490-
val emptyCids =
2498+
val emptyCards =
24912499
withProgress(R.string.emtpy_cards_finding) {
2492-
withCol {
2493-
emptyCids()
2494-
}
2500+
viewModel.findEmptyCards()
24952501
}
24962502
AlertDialog.Builder(this@DeckPicker).show {
24972503
setTitle(TR.emptyCardsWindowTitle())
2498-
if (emptyCids.isEmpty()) {
2504+
if (emptyCards.isEmpty()) {
24992505
setMessage(TR.emptyCardsNotFound())
25002506
setPositiveButton(R.string.dialog_ok) { _, _ -> }
25012507
} else {
2502-
setMessage(getString(R.string.empty_cards_count, emptyCids.size))
2508+
setMessage(getString(R.string.empty_cards_count, emptyCards.size))
25032509
setPositiveButton(R.string.dialog_positive_delete) { _, _ ->
25042510
launchCatchingTask {
25052511
withProgress(TR.emptyCardsDeleting()) {
2506-
withCol { removeCardsAndOrphanedNotes(emptyCids) }
2512+
viewModel.deleteEmptyCards(emptyCards).join()
25072513
}
25082514
}
2509-
showSnackbar(getString(R.string.empty_cards_deleted, emptyCids.size))
25102515
}
25112516
setNegativeButton(R.string.dialog_cancel) { _, _ -> }
25122517
}

AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import anki.i18n.GeneratedTranslations
2323
import com.ichi2.anki.CollectionManager.TR
2424
import com.ichi2.anki.CollectionManager.withCol
2525
import com.ichi2.anki.DeckPicker
26+
import com.ichi2.libanki.CardId
2627
import com.ichi2.libanki.Consts
2728
import com.ichi2.libanki.DeckId
2829
import com.ichi2.libanki.undoableOp
@@ -36,6 +37,7 @@ class DeckPickerViewModel : ViewModel() {
3637
* @see DeckDeletionResult
3738
*/
3839
val deckDeletedNotification = MutableSharedFlow<DeckDeletionResult>()
40+
val emptyCardsNotification = MutableSharedFlow<EmptyCardsResult>()
3941

4042
/**
4143
* Keep track of which deck was last given focus in the deck list. If we find that this value
@@ -77,6 +79,19 @@ class DeckPickerViewModel : ViewModel() {
7779
val targetDeckId = withCol { decks.selected() }
7880
deleteDeck(targetDeckId).join()
7981
}
82+
83+
/** Returns a list of cards to be passed to [deleteEmptyCards] (after user confirmation) */
84+
suspend fun findEmptyCards() = EmptyCards(withCol { emptyCids() })
85+
86+
/**
87+
* Removes the provided list of cards from the collection.
88+
* @param emptyCards Cards to be deleted, result of [findEmptyCards]
89+
*/
90+
fun deleteEmptyCards(emptyCards: EmptyCards) =
91+
viewModelScope.launch {
92+
val result = undoableOp { removeCardsAndOrphanedNotes(emptyCards) }
93+
emptyCardsNotification.emit(EmptyCardsResult(cardsDeleted = result.count))
94+
}
8095
}
8196

8297
/** Result of [DeckPickerViewModel.deleteDeck] */
@@ -95,3 +110,21 @@ data class DeckDeletionResult(
95110
deckName = deckName,
96111
)
97112
}
113+
114+
/**
115+
* Result of [DeckPickerViewModel.findEmptyCards], used in [DeckPickerViewModel.deleteEmptyCards]
116+
*/
117+
@JvmInline
118+
value class EmptyCards(
119+
val cards: List<CardId>,
120+
) : List<CardId> by cards
121+
122+
/** Result of [DeckPickerViewModel.deleteEmptyCards] */
123+
data class EmptyCardsResult(
124+
val cardsDeleted: Int,
125+
) {
126+
/**
127+
* @see GeneratedTranslations.emptyCardsDeletedCount */
128+
@CheckResult
129+
fun toHumanReadableString() = TR.emptyCardsDeletedCount(cardsDeleted)
130+
}

AnkiDroid/src/main/java/com/ichi2/libanki/Collection.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -542,9 +542,11 @@ class Collection(
542542
Timber.d("removeNotes: %d changes", it.count)
543543
}
544544

545-
fun removeCardsAndOrphanedNotes(cardIds: Iterable<Long>) {
546-
backend.removeCards(cardIds)
547-
}
545+
/**
546+
* @return the number of deleted cards. **Note:** if an invalid/duplicate [CardId] is provided,
547+
* the output count may be less than the input.
548+
*/
549+
fun removeCardsAndOrphanedNotes(cardIds: Iterable<CardId>) = backend.removeCards(cardIds)
548550

549551
fun addNote(
550552
note: Note,

AnkiDroid/src/main/res/values/03-dialogs.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@
116116
<!-- Empty cards -->
117117
<string name="emtpy_cards_finding">Finding empty cards…</string>
118118
<string name="empty_cards_count">Cards to delete: %d</string>
119-
<string name="empty_cards_deleted">Cards deleted: %d</string>
120119

121120

122121
<!-- Multimedia - Edit Field Activity -->
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright (c) 2025 David Allison <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.deckpicker
18+
19+
import androidx.annotation.CheckResult
20+
import androidx.test.ext.junit.runners.AndroidJUnit4
21+
import app.cash.turbine.test
22+
import com.ichi2.anki.RobolectricTest
23+
import com.ichi2.libanki.CardId
24+
import com.ichi2.libanki.undoStatus
25+
import com.ichi2.testutils.ensureOpsExecuted
26+
import org.hamcrest.CoreMatchers.not
27+
import org.hamcrest.MatcherAssert.assertThat
28+
import org.hamcrest.Matchers.empty
29+
import org.hamcrest.Matchers.equalTo
30+
import org.junit.Test
31+
import org.junit.runner.RunWith
32+
import timber.log.Timber
33+
34+
/** Test of [DeckPickerViewModel] */
35+
@RunWith(AndroidJUnit4::class)
36+
class DeckPickerViewModelTest : RobolectricTest() {
37+
private val viewModel = DeckPickerViewModel()
38+
39+
@Test
40+
fun `empty cards - flow`() =
41+
runTest {
42+
val cardsToEmpty = createEmptyCards()
43+
44+
viewModel.emptyCardsNotification.test {
45+
// test a 'normal' deletion
46+
viewModel.deleteEmptyCards(cardsToEmpty).join()
47+
48+
expectMostRecentItem().also {
49+
assertThat("cards deleted", it.cardsDeleted, equalTo(1))
50+
}
51+
52+
// ensure a duplicate output is displayed to the user
53+
val newCardsToEmpty = createEmptyCards()
54+
viewModel.deleteEmptyCards(newCardsToEmpty).join()
55+
56+
expectMostRecentItem().also {
57+
assertThat("cards deleted: duplicate output", it.cardsDeleted, equalTo(1))
58+
}
59+
60+
// send the same collection in, but with the same ids.
61+
// the output should only show 1 card deleted
62+
val emptyCardsSentTwice = createEmptyCards()
63+
viewModel.deleteEmptyCards(emptyCardsSentTwice + emptyCardsSentTwice).join()
64+
65+
expectMostRecentItem().also {
66+
assertThat("cards deleted: duplicate input", it.cardsDeleted, equalTo(1))
67+
}
68+
69+
// test an empty list: a no-op should inform the user, rather than do nothing
70+
viewModel.deleteEmptyCards(listOf()).join()
71+
72+
expectMostRecentItem().also {
73+
assertThat("'no cards deleted' is notified", it.cardsDeleted, equalTo(0))
74+
}
75+
}
76+
}
77+
78+
@Test
79+
fun `empty cards - undoable`() =
80+
runTest {
81+
val cardsToEmpty = createEmptyCards()
82+
83+
// ChangeManager assert
84+
ensureOpsExecuted(1) {
85+
viewModel.deleteEmptyCards(cardsToEmpty).join()
86+
}
87+
88+
// backend assert
89+
assertThat("col undo status", col.undoStatus().undo, equalTo("Empty Cards"))
90+
}
91+
92+
@CheckResult
93+
private suspend fun createEmptyCards(): List<CardId> {
94+
addNoteUsingNoteTypeName("Cloze", "{{c1::Hello}} {{c2::World}}", "").apply {
95+
setField(0, "{{c1::Hello}}")
96+
col.updateNote(this)
97+
}
98+
return viewModel.findEmptyCards().also { cardsToEmpty ->
99+
assertThat("there are empty cards", cardsToEmpty, not(empty()))
100+
Timber.d("created %d empty cards: [%s]", cardsToEmpty.size, cardsToEmpty)
101+
}
102+
}
103+
104+
/** test helper to use [deleteEmptyCards] without an [EmptyCards] instance */
105+
private fun DeckPickerViewModel.deleteEmptyCards(list: List<CardId>) = deleteEmptyCards(EmptyCards(list))
106+
}

0 commit comments

Comments
 (0)