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 @@ -799,7 +799,7 @@ abstract class AbstractFlashcardViewer :
}
val animation = fromGesture.toAnimationTransition().invert()
Timber.i("Launching 'edit card'")
val editCardIntent = NoteEditorLauncher.EditCard(currentCard!!.id, animation).toIntent(this)
val editCardIntent = NoteEditorLauncher.EditSelection(currentCard!!.id, animation).toIntent(this)
editCurrentCardLauncher.launch(editCardIntent)
}

Expand Down
22 changes: 13 additions & 9 deletions AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ open class CardBrowser :
*/
private val editNoteLauncher: NoteEditorLauncher
get() =
NoteEditorLauncher.EditCard(viewModel.currentCardId, Direction.DEFAULT, fragmented).also {
NoteEditorLauncher.EditSelection(viewModel.currentCardId, Direction.DEFAULT, fragmented).also {
Timber.i("editNoteLauncher: %s", it)
}

Expand Down Expand Up @@ -709,18 +709,22 @@ open class CardBrowser :
cardBrowserFragment.updateFlagForSelectedRows(flag)
}

/** Opens the note editor for a card.
* We use the Card ID to specify the preview target */
/**
* Opens the note editor for the given card.
*
* @param cardId The ID of the card to open in the note editor.
* Passing `null` indicates that no card is selected and will close the note editor
*/
@NeedsTest("note edits are saved")
@NeedsTest("I/O edits are saved")
fun openNoteEditorForCard(cardId: CardId) {
viewModel.openNoteEditorForCard(cardId)
fun setNoteEditorCard(cardId: CardId?) {
viewModel.setNoteEditorCard(cardId)
}

/**
* In case of selection, the first card that was selected, otherwise the first card of the list.
*/
private suspend fun getCardIdForNoteEditor(): CardId {
private suspend fun getCardIdForNoteEditor(): CardId? {
// Just select the first one if there's a multiselect occurring.
return if (viewModel.isInMultiSelectMode) {
viewModel.querySelectedCardIdAtPosition(0)
Expand All @@ -739,7 +743,7 @@ open class CardBrowser :

try {
val cardId = getCardIdForNoteEditor()
openNoteEditorForCard(cardId)
setNoteEditorCard(cardId)
} catch (e: Exception) {
Timber.w(e, "Error Opening Note Editor")
showSnackbar(R.string.multimedia_editor_something_wrong)
Expand Down Expand Up @@ -1143,10 +1147,10 @@ open class CardBrowser :
updateList()
// Check whether deck is empty or not
val isDeckEmpty = viewModel.rowCount == 0
val currentCardId = viewModel.updateCurrentCardId()
// Hide note editor frame if deck is empty and fragmented
binding.noteEditorFrame?.visibility =
if (fragmented && !isDeckEmpty) {
viewModel.currentCardId = (viewModel.focusedRow ?: viewModel.cards[0]).toCardId(viewModel.cardsOrNotes)
if (fragmented && !isDeckEmpty && currentCardId != null) {
loadNoteEditorFragmentIfFragmented()
View.VISIBLE
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ class CardBrowserFragment :
}
} else {
val cardId = activityViewModel.queryDataForCardEdit(id)
requireCardBrowserActivity().openNoteEditorForCard(cardId)
requireCardBrowserActivity().setNoteEditorCard(cardId)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,22 @@ class CardBrowserViewModel(
val cardsOrNotes get() = flowOfCardsOrNotes.value

// card that was clicked (not marked)
var currentCardId: CardId = 0
var currentCardId: CardId? = null

/**
* Computes and stores the current card ID used by the note editor.
*/
suspend fun updateCurrentCardId(): CardId? {
currentCardId =
// Early return if no cards available
if (cards.isEmpty()) {
null
} else {
focusedRow?.toCardId(cardsOrNotes)
?: cards.firstOrNull()?.toCardId(cardsOrNotes)
}
return currentCardId
}

var cardIdToBeScrolledTo: CardId? = null
private set
Expand Down Expand Up @@ -332,7 +347,7 @@ class CardBrowserViewModel(
return CardInfoDestination(firstSelectedCard, TR.currentCardBrowse())
}

suspend fun queryDataForCardEdit(id: CardOrNoteId): CardId = id.toCardId(cardsOrNotes)
suspend fun queryDataForCardEdit(id: CardOrNoteId): CardId? = id.toCardId(cardsOrNotes)

private suspend fun getInitialDeck(): SelectableDeck {
// TODO: Handle the launch intent
Expand Down Expand Up @@ -564,8 +579,13 @@ class CardBrowserViewModel(
}
}

// on a row tap
fun openNoteEditorForCard(cardId: CardId) {
/**
* Opens the note editor for the given card.
*
* @param cardId The ID of the card to open in the note editor.
* Passing `null` indicates that no card is selected and will close the note editor
*/
fun setNoteEditorCard(cardId: CardId?) {
currentCardId = cardId
if (!isFragmented) {
endMultiSelectMode(SingleSelectCause.OpenNoteEditorActivity)
Expand Down Expand Up @@ -1236,7 +1256,7 @@ class CardBrowserViewModel(

suspend fun queryCardIdAtPosition(index: Int): CardId = cards.queryCardIdsAt(index).first()

suspend fun querySelectedCardIdAtPosition(index: Int): CardId = selectedRows.toList()[index].toCardId(cardsOrNotes)
suspend fun querySelectedCardIdAtPosition(index: Int): CardId? = selectedRows.toList()[index].toCardId(cardsOrNotes)

/**
* Obtains two lists of column headings with preview data
Expand Down
11 changes: 9 additions & 2 deletions AnkiDroid/src/main/java/com/ichi2/anki/browser/CardOrNoteId.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,16 @@ value class CardOrNoteId(

// TODO: We use this for 'Edit Note' or 'Card Info'. We should reconsider whether we ever want
// to move from NoteId to CardId. Our move to 'Notes' mode wasn't well thought-through
suspend fun toCardId(type: CardsOrNotes): CardId =

// TODO: Notes without cards likely indicate an invalid or corrupted collection state.
// We currently handle this gracefully by returning an empty list,
// we may want to surface this as a warning or integrity check for the user.
suspend fun toCardId(type: CardsOrNotes): CardId? =
when (type) {
CardsOrNotes.CARDS -> cardOrNoteId
CardsOrNotes.NOTES -> withCol { cardIdsOfNote(cardOrNoteId).first() }
// A note can map to multiple cards or none at all.
// See [cardIdsOfNote] for the full explanation and edge cases
// (empty templates, orphaned notes, etc).
CardsOrNotes.NOTES -> withCol { cardIdsOfNote(cardOrNoteId).firstOrNull() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,12 @@ sealed interface NoteEditorLauncher : Destination {
}

/**
* Represents editing a card in the NoteEditor.
* @property cardId The ID of the card to edit.
* Opens the NoteEditor for the current selection (card or note).
* @property cardId The selected card ID, or null when editing a note.
* @property animation The animation direction.
*/
data class EditCard(
val cardId: CardId,
data class EditSelection(
val cardId: CardId?,
val animation: ActivityTransitionAnimation.Direction,
val inCardBrowserActivity: Boolean = false,
) : NoteEditorLauncher {
Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ class NoteEditorTest : RobolectricTest() {
): NoteEditorFragment {
val bundle =
when (from) {
REVIEWER -> NoteEditorLauncher.EditCard(n.firstCard().id, DEFAULT).toBundle()
REVIEWER -> NoteEditorLauncher.EditSelection(n.firstCard().id, DEFAULT).toBundle()
DECK_LIST -> NoteEditorLauncher.AddNote().toBundle()
}
return openNoteEditorWithArgs(bundle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ class CardBrowserViewModelTest : JvmTest() {
assertThat("Deck should be changed", col.getCard(cardId).did, equalTo(newDeck))
}

val hasSomeDecksUnchanged = cards.any { row -> col.getCard(row.toCardId(cardsOrNotes)).did != newDeck }
val hasSomeDecksUnchanged = cards.any { row -> col.getCard(row.requireCardId(cardsOrNotes)).did != newDeck }
assertThat("some decks are unchanged", hasSomeDecksUnchanged)
}

Expand Down Expand Up @@ -633,7 +633,7 @@ class CardBrowserViewModelTest : JvmTest() {
@Test
fun `suspend - cards - some suspended`() =
runViewModelTest(notes = 2) {
suspendCards(cards.first().toCardId(cardsOrNotes))
suspendCards(cards.first().requireCardId(cardsOrNotes))
ensureOpsExecuted(1) {
selectAll()
toggleSuspendCards()
Expand Down Expand Up @@ -689,7 +689,7 @@ class CardBrowserViewModelTest : JvmTest() {
fun `suspend - notes - some cards suspended`() =
runViewModelNotesTest(notes = 2) {
// this suspends o single cid from a nid
suspendCards(cards.first().toCardId(cardsOrNotes))
suspendCards(cards.first().requireCardId(cardsOrNotes))
ensureOpsExecuted(1) {
selectAll()
toggleSuspendCards()
Expand Down Expand Up @@ -1263,6 +1263,24 @@ class CardBrowserViewModelTest : JvmTest() {
}
}

@Test
fun `notes mode - a note maps to all its cards`() =
runViewModelNotesTest(notes = 1) {
// One Basic+Reversed note = 2 cards
assertThat("one note", rowCount, equalTo(1))
val row = cards.single()
// When in NOTES mode, selecting a row should map to all its cards
val cardIds = BrowserRowCollection(cardsOrNotes, mutableListOf(row)).queryCardIds()
assertThat(
"a single note expands to all its cards",
cardIds,
hasSize(2),
)
for (cardId in cardIds) {
assertNotNull(col.getCard(cardId))
}
}

private fun assertDate(str: String?) {
// 2025-01-09 @ 18:06
assertNotNull(str)
Expand Down Expand Up @@ -1447,6 +1465,9 @@ private fun AnkiTest.suspendNote(note: Note) {
col.sched.suspendCards(note.cardIds(col))
}

suspend fun CardOrNoteId.requireCardId(cardsOrNotes: CardsOrNotes): CardId =
toCardId(cardsOrNotes) ?: error("Expected card ID to be non-null for $this in $cardsOrNotes mode")

val CardBrowserViewModel.column1
get() = this.activeColumns[0]

Expand Down
10 changes: 10 additions & 0 deletions libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,16 @@ class Collection(
backend.removeNotes(noteIds = emptyList(), cardIds = cardIds)
}

/**
* Returns all card IDs linked to the given note.
*
* IMPORTANT:
* A note may not always have cards.
*
* This can happen in cases like:
* - The note type has no card templates (empty cards).
* - Cards were deleted but the note still exists (orphaned notes).
*/
@CheckResult
@LibAnkiAlias("card_ids_of_note")
fun cardIdsOfNote(nid: NoteId): List<CardId> = backend.cardsOfNote(nid = nid)
Expand Down