Skip to content
Merged
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
29 changes: 29 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import com.ichi2.anki.browser.CardBrowserLaunchOptions
import com.ichi2.anki.browser.CardBrowserViewModel
import com.ichi2.anki.browser.CardBrowserViewModel.ChangeMultiSelectMode
import com.ichi2.anki.browser.CardBrowserViewModel.ChangeMultiSelectMode.SingleSelectCause
import com.ichi2.anki.browser.CardBrowserViewModel.ChangeNoteTypeResponse
import com.ichi2.anki.browser.CardBrowserViewModel.SearchState
import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Initializing
import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Searching
Expand All @@ -67,6 +68,7 @@ import com.ichi2.anki.browser.toCardBrowserLaunchOptions
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
import com.ichi2.anki.databinding.ActivityCardBrowserBinding
import com.ichi2.anki.dialogs.ChangeNoteTypeDialog
import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener
import com.ichi2.anki.dialogs.DiscardChangesDialog
import com.ichi2.anki.dialogs.GradeNowDialog
Expand Down Expand Up @@ -546,6 +548,19 @@ open class CardBrowser :
showDialogFragment(dialog)
}

fun onChangeNoteType(result: ChangeNoteTypeResponse) {
when (result) {
ChangeNoteTypeResponse.NoSelection -> {
Timber.w("change note type: no selection")
}
ChangeNoteTypeResponse.MixedSelection -> showSnackbar(R.string.different_note_types_selected)
is ChangeNoteTypeResponse.ChangeNoteType -> {
val dialog = ChangeNoteTypeDialog.newInstance(result.noteIds)
showDialogFragment(dialog)
}
}
}

viewModel.flowOfSearchQueryExpanded.launchCollectionInLifecycleScope(::onSearchQueryExpanded)
viewModel.flowOfSelectedRows.launchCollectionInLifecycleScope(::onSelectedRowsChanged)
viewModel.flowOfFilterQuery.launchCollectionInLifecycleScope(::onFilterQueryChanged)
Expand All @@ -555,6 +570,7 @@ open class CardBrowser :
viewModel.flowOfSearchState.launchCollectionInLifecycleScope(::searchStateChanged)
viewModel.cardSelectionEventFlow.launchCollectionInLifecycleScope(::onSelectedCardUpdated)
viewModel.flowOfSaveSearchNamePrompt.launchCollectionInLifecycleScope(::onSaveSearchNamePrompt)
viewModel.flowOfChangeNoteType.launchCollectionInLifecycleScope(::onChangeNoteType)
}

fun isKeyboardVisible(view: View?): Boolean =
Expand Down Expand Up @@ -665,6 +681,13 @@ open class CardBrowser :
return true
}
}
KeyEvent.KEYCODE_M -> {
if (event.isCtrlPressed && event.isShiftPressed) {
Timber.i("Ctrl+Shift+M: Change Note Type")
viewModel.requestChangeNoteType()
return true
}
}
KeyEvent.KEYCODE_Z -> {
if (event.isCtrlPressed) {
Timber.i("Ctrl+Z: Undo")
Expand Down Expand Up @@ -854,6 +877,7 @@ open class CardBrowser :
isVisible = isFindReplaceEnabled
title = TR.browsingFindAndReplace().toSentenceCase(this@CardBrowser, R.string.sentence_find_and_replace)
}

previewItem = menu.findItem(R.id.action_preview)
onSelectionChanged()
refreshMenuItems()
Expand Down Expand Up @@ -1016,6 +1040,11 @@ open class CardBrowser :
showSavedSearches()
return true
}
R.id.action_change_note_type -> {
Timber.i("Menu: Change note type")
viewModel.requestChangeNoteType()
return true
}
R.id.action_undo -> {
Timber.w("CardBrowser:: Undo pressed")
onUndo()
Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2495,7 +2495,7 @@ class OneWaySyncDialog(
/**
* [launchCatchingTask], showing a one-way sync dialog: [R.string.full_sync_confirmation]
*/
private fun AnkiActivity.launchCatchingRequiringOneWaySync(block: suspend () -> Unit) =
fun AnkiActivity.launchCatchingRequiringOneWaySync(block: suspend () -> Unit) =
launchCatchingTask {
try {
block()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,7 @@ class NoteEditorFragment :
* Change the note type from oldNoteType to newNoteType, handling the case where a full sync will be required
*/
@NeedsTest("test changing note type")
@Suppress("Deprecation") // Replace with ChangeNoteTypeDialog
private fun changeNoteType(
oldNotetype: NotetypeJson,
newNotetype: NotetypeJson,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.CardType
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.libanki.NoteId
import com.ichi2.anki.libanki.QueueType
import com.ichi2.anki.libanki.QueueType.ManuallyBuried
import com.ichi2.anki.libanki.QueueType.SiblingBuried
Expand Down Expand Up @@ -267,6 +268,8 @@ class CardBrowserViewModel(
*/
val flowOfCardStateChanged = MutableSharedFlow<Unit>()

val flowOfChangeNoteType = MutableSharedFlow<ChangeNoteTypeResponse>()

/**
* Opens a prompt for the user to input a saved search name
*
Expand All @@ -284,6 +287,19 @@ class CardBrowserViewModel(

suspend fun queryAllSelectedNoteIds() = selectedRows.queryNoteIds(this.cardsOrNotes)

fun requestChangeNoteType() =
viewModelScope.launch {
val noteIds = queryAllSelectedNoteIds()
Timber.i("requestChangeNoteType: querying %d selected notes", noteIds.size)
flowOfChangeNoteType.emit(
when {
noteIds.isEmpty() -> ChangeNoteTypeResponse.NoSelection
!noteIds.allOfSameNoteType() -> ChangeNoteTypeResponse.MixedSelection
else -> ChangeNoteTypeResponse.ChangeNoteType.from(noteIds)
},
)
}

@VisibleForTesting
internal suspend fun queryAllCardIds() = cards.queryCardIds()

Expand Down Expand Up @@ -1351,6 +1367,25 @@ class CardBrowserViewModel(
SELECT_NONE,
}

sealed interface ChangeNoteTypeResponse {
data object NoSelection : ChangeNoteTypeResponse

data object MixedSelection : ChangeNoteTypeResponse

@ConsistentCopyVisibility
data class ChangeNoteType private constructor(
val noteIds: List<NoteId>,
) : ChangeNoteTypeResponse {
companion object {
@CheckResult
fun from(ids: List<NoteId>): ChangeNoteType {
require(ids.isNotEmpty()) { "a non-empty list must be provided" }
return ChangeNoteType(ids.distinct())
}
}
}
}

/**
* @param wasBuried `true` if all cards were buried, `false` if unburied
* @param count the number of affected cards
Expand Down Expand Up @@ -1515,6 +1550,16 @@ sealed class RepositionCardsRequest {

fun BrowserColumns.Column.getLabel(cardsOrNotes: CardsOrNotes): String = if (cardsOrNotes == CARDS) cardsModeLabel else notesModeLabel

/**
* Whether the provided notes all have the same the same [note type][com.ichi2.anki.libanki.NoteTypeId]
*/
private suspend fun List<NoteId>.allOfSameNoteType(): Boolean {
val noteIds = this
return withCol { notetypes.nids(getNote(noteIds.first()).noteTypeId) }.toSet().let { set ->
noteIds.all { set.contains(it) }
Comment on lines +1554 to +1559
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

[nitpick] Potential performance issue: allOfSameNoteType() calls withCol { notetypes.nids(...) } which may be expensive for large note collections. This is called for every "change note type" request. Consider adding a comment about the performance characteristics or optimizing for cases where all selected notes are guaranteed to be from the same type (e.g., if selection was filtered by note type).

Suggested change
* Whether the provided notes all have the same the same [note type][com.ichi2.anki.libanki.NoteTypeId]
*/
private suspend fun List<NoteId>.allOfSameNoteType(): Boolean {
val noteIds = this
return withCol { notetypes.nids(getNote(noteIds.first()).noteTypeId) }.toSet().let { set ->
noteIds.all { set.contains(it) }
* Whether the provided notes all have the same [note type][com.ichi2.anki.libanki.NoteTypeId].
*
* Performance: This function previously loaded all note IDs of the first note's type, which could be
* expensive for large collections. It now fetches the note type for each provided note ID and checks
* if they are all the same. This is more efficient when the number of selected notes is small.
*/
private suspend fun List<NoteId>.allOfSameNoteType(): Boolean {
if (isEmpty()) return true
return withCol {
val firstType = getNote(this@allOfSameNoteType.first()).noteTypeId
this@allOfSameNoteType.all { getNote(it).noteTypeId == firstType }

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

This is a bad suggestion

}
}

@Parcelize
data class ColumnHeading(
val label: String,
Expand Down
Loading