Skip to content

Commit f946f26

Browse files
lonewolf2208mikehardy
authored andcommitted
api: add raw card scheduling fields to card provider
api: updated raw card scheduling fields to card provider api: tighten raw card field docs Assisted-by: GPT-5.4 api: clarify raw due scheduler docs
1 parent 490c6d5 commit f946f26

3 files changed

Lines changed: 339 additions & 0 deletions

File tree

AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.ichi2.anki.FlashCardsContract
2929
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
3030
import com.ichi2.anki.common.utils.emptyStringArray
3131
import com.ichi2.anki.libanki.Card
32+
import com.ichi2.anki.libanki.CardType
3233
import com.ichi2.anki.libanki.DeckId
3334
import com.ichi2.anki.libanki.Decks
3435
import com.ichi2.anki.libanki.Note
@@ -601,6 +602,182 @@ class ContentProviderTest : InstrumentedTest() {
601602
)
602603
}
603604

605+
@Test
606+
fun testQueryCardById_withRawQueueProjection() {
607+
val expectedRawQueue = 2
608+
val card = updateCardForRawFieldQuery(rawQueue = expectedRawQueue)
609+
610+
assertProjectedCardInt(card.id, FlashCardsContract.Card.RAW_QUEUE, expectedRawQueue)
611+
}
612+
613+
@Test
614+
fun testQueryCardById_withRawDueProjection() {
615+
val expectedRawQueue = 1
616+
val expectedRawDue = 123456789
617+
val card =
618+
updateCardForRawFieldQuery(
619+
rawQueue = expectedRawQueue,
620+
rawDue = expectedRawDue,
621+
)
622+
623+
assertProjectedCardInt(card.id, FlashCardsContract.Card.RAW_DUE, expectedRawDue)
624+
}
625+
626+
@Test
627+
fun testQueryCardById_withRawOriginalDueProjection() {
628+
val expectedRawDue = 654321
629+
val expectedRawOriginalDue = 765432
630+
val card =
631+
updateCardForRawFieldQuery(
632+
rawDue = expectedRawDue,
633+
rawOriginalDue = expectedRawOriginalDue,
634+
)
635+
636+
assertProjectedCardInt(
637+
card.id,
638+
FlashCardsContract.Card.RAW_ORIGINAL_DUE,
639+
expectedRawOriginalDue,
640+
)
641+
}
642+
643+
@Test
644+
fun testQueryCardById_withFilteredDeckRawDueProjection() {
645+
val originalDue = col.sched.today + 25
646+
val card =
647+
updateCardForRawFieldQuery(
648+
rawQueue = 2,
649+
rawDue = originalDue,
650+
rawInterval = 50,
651+
)
652+
653+
// Move the card into a filtered deck so due is replaced and the original due is kept in oDue.
654+
val filteredDeckId = col.decks.newFiltered("Raw due filtered deck")
655+
testDeckIds.add(filteredDeckId)
656+
val filteredDeck = checkNotNull(col.decks.getLegacy(filteredDeckId))
657+
filteredDeck.getJSONArray("terms").getJSONArray(0).put(0, "cid:${card.id}")
658+
col.decks.save(filteredDeck)
659+
col.sched.rebuildFilteredDeck(filteredDeckId)
660+
card.load(col)
661+
662+
assertNotEquals(originalDue, card.due)
663+
assertEquals(originalDue, card.oDue)
664+
assertProjectedCardInt(card.id, FlashCardsContract.Card.RAW_DUE, card.due)
665+
assertProjectedCardInt(card.id, FlashCardsContract.Card.RAW_ORIGINAL_DUE, card.oDue)
666+
}
667+
668+
@Test
669+
fun testQueryCardById_withRawIntervalProjection() {
670+
val expectedRawInterval = 37
671+
val card = updateCardForRawFieldQuery(rawInterval = expectedRawInterval)
672+
673+
assertProjectedCardInt(card.id, FlashCardsContract.Card.INTERVAL, expectedRawInterval)
674+
}
675+
676+
@Test
677+
fun testQueryCardById_withRawSm2FactorProjection() {
678+
val expectedRawSm2Factor = 2870
679+
val card = updateCardForRawFieldQuery(rawSm2Factor = expectedRawSm2Factor)
680+
681+
assertProjectedCardInt(
682+
card.id,
683+
FlashCardsContract.Card.RAW_SM2_FACTOR,
684+
expectedRawSm2Factor,
685+
)
686+
}
687+
688+
@Test
689+
fun testQueryCardById_withRawLeftProjection() {
690+
val expectedRawLeft = 2003
691+
val card = updateCardForRawFieldQuery(rawLeft = expectedRawLeft)
692+
693+
assertProjectedCardInt(card.id, FlashCardsContract.Card.RAW_LEFT, expectedRawLeft)
694+
}
695+
696+
@Test
697+
fun testQueryCardById_defaultProjectionDoesNotIncludeRawFields() {
698+
val card =
699+
updateCardForRawFieldQuery(
700+
rawQueue = 2,
701+
rawDue = 99,
702+
rawOriginalDue = 88,
703+
rawInterval = 77,
704+
rawSm2Factor = 2500,
705+
rawLeft = 66,
706+
)
707+
708+
val cardUri =
709+
Uri.withAppendedPath(
710+
FlashCardsContract.Card.CONTENT_URI,
711+
card.id.toString(),
712+
)
713+
714+
val cursor = contentResolver.cursorFor(cardUri)
715+
716+
cursor.use {
717+
assertEquals(FlashCardsContract.Card.DEFAULT_PROJECTION.toList(), it.columnNames.toList())
718+
assertTrue("default projection cursor should contain a row", it.moveToFirst())
719+
720+
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.RAW_QUEUE))
721+
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.RAW_DUE))
722+
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.RAW_ORIGINAL_DUE))
723+
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.INTERVAL))
724+
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.RAW_SM2_FACTOR))
725+
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.RAW_LEFT))
726+
}
727+
}
728+
729+
@Test
730+
fun testSearchCards_withRawQueueProjection() {
731+
val expectedRawQueue = 2
732+
val card = updateCardForRawFieldQuery(rawQueue = expectedRawQueue)
733+
734+
val cursor =
735+
contentResolver.cursorFor(
736+
FlashCardsContract.Card.CONTENT_URI,
737+
projection = arrayOf(FlashCardsContract.Card.RAW_QUEUE),
738+
selection = "cid:${card.id}",
739+
)
740+
741+
cursor.use {
742+
assertEquals(listOf(FlashCardsContract.Card.RAW_QUEUE), it.columnNames.toList())
743+
assertTrue("search cursor should contain a row", it.moveToFirst())
744+
assertEquals(expectedRawQueue, it.getInt(it.getColumnIndex(FlashCardsContract.Card.RAW_QUEUE)))
745+
}
746+
}
747+
748+
@Test
749+
fun testQueryNoteCardByOrd_withRawDueProjection() {
750+
val expectedRawQueue = 1
751+
val expectedRawDue = 24680
752+
val card =
753+
updateCardForRawFieldQuery(
754+
rawQueue = expectedRawQueue,
755+
rawDue = expectedRawDue,
756+
)
757+
758+
val noteCardsUri =
759+
Uri.withAppendedPath(
760+
Uri.withAppendedPath(
761+
FlashCardsContract.Note.CONTENT_URI,
762+
card.nid.toString(),
763+
),
764+
"cards",
765+
)
766+
val specificCardUri = Uri.withAppendedPath(noteCardsUri, card.ord.toString())
767+
768+
val cursor =
769+
contentResolver.cursorFor(
770+
specificCardUri,
771+
projection = arrayOf(FlashCardsContract.Card.RAW_DUE),
772+
)
773+
774+
cursor.use {
775+
assertEquals(listOf(FlashCardsContract.Card.RAW_DUE), it.columnNames.toList())
776+
assertTrue("note card cursor should contain a row", it.moveToFirst())
777+
assertEquals(expectedRawDue, it.getInt(it.getColumnIndex(FlashCardsContract.Card.RAW_DUE)))
778+
}
779+
}
780+
604781
/**
605782
* Check that inserting a note with an invalid noteTypeId returns a reasonable exception
606783
*/
@@ -1821,6 +1998,53 @@ class ContentProviderTest : InstrumentedTest() {
18211998
return contentResolver.delete(emptyCardsUri, null, null)
18221999
}
18232000

2001+
private fun updateCardForRawFieldQuery(
2002+
rawQueue: Int = 0,
2003+
rawDue: Int = 0,
2004+
rawOriginalDue: Int = 0,
2005+
rawInterval: Int = 0,
2006+
rawSm2Factor: Int = 0,
2007+
rawLeft: Int = 0,
2008+
): Card {
2009+
val card = getFirstCardFromScheduler(col) ?: error("No card available for raw field test")
2010+
card.queue = QueueType.fromCode(rawQueue)
2011+
card.type =
2012+
when (rawQueue) {
2013+
0 -> CardType.New
2014+
1 -> CardType.Lrn
2015+
2 -> CardType.Rev
2016+
3 -> CardType.Relearning
2017+
else -> card.type
2018+
}
2019+
card.due = rawDue
2020+
card.oDue = rawOriginalDue
2021+
card.ivl = rawInterval
2022+
card.factor = rawSm2Factor
2023+
card.left = rawLeft
2024+
col.updateCard(card, skipUndoEntry = true)
2025+
return card
2026+
}
2027+
2028+
private fun assertProjectedCardInt(
2029+
cardId: Long,
2030+
columnName: String,
2031+
expectedValue: Int,
2032+
) {
2033+
val cardUri =
2034+
Uri.withAppendedPath(
2035+
FlashCardsContract.Card.CONTENT_URI,
2036+
cardId.toString(),
2037+
)
2038+
2039+
val cursor = contentResolver.cursorFor(cardUri, projection = arrayOf(columnName))
2040+
2041+
cursor.use {
2042+
assertEquals(listOf(columnName), it.columnNames.toList())
2043+
assertTrue("card cursor should contain a row", it.moveToFirst())
2044+
assertEquals(expectedValue, it.getInt(it.getColumnIndex(columnName)))
2045+
}
2046+
}
2047+
18242048
/** Adds a note which will be removed by [tearDown] */
18252049
private fun addTempClozeNote(text: String): Note =
18262050
addClozeNote(text).update {
@@ -1911,6 +2135,17 @@ class ContentProviderTest : InstrumentedTest() {
19112135
}
19122136
}
19132137

2138+
private fun ContentResolver.cursorFor(
2139+
uri: Uri,
2140+
projection: Array<String>? = null,
2141+
selection: String? = null,
2142+
selectionArgs: Array<String>? = null,
2143+
sortOrder: String? = null,
2144+
): Cursor =
2145+
checkNotNull(query(uri, projection, selection, selectionArgs, sortOrder)) {
2146+
"null cursor from $uri"
2147+
}
2148+
19142149
/**
19152150
* Unbury all buried cards in all decks. Only used for tests.
19162151
*/

AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,12 @@ class CardContentProvider : ContentProvider() {
11871187
FlashCardsContract.Card.QUESTION_SIMPLE -> rb.add(currentCard.renderOutput(col).questionText)
11881188
FlashCardsContract.Card.ANSWER_SIMPLE -> rb.add(currentCard.renderOutput(col, false).answerText)
11891189
FlashCardsContract.Card.ANSWER_PURE -> rb.add(currentCard.pureAnswer(col))
1190+
FlashCardsContract.Card.RAW_QUEUE -> rb.add(currentCard.queue.code)
1191+
FlashCardsContract.Card.RAW_DUE -> rb.add(currentCard.due)
1192+
FlashCardsContract.Card.RAW_ORIGINAL_DUE -> rb.add(currentCard.oDue)
1193+
FlashCardsContract.Card.INTERVAL -> rb.add(currentCard.ivl)
1194+
FlashCardsContract.Card.RAW_SM2_FACTOR -> rb.add(currentCard.factor)
1195+
FlashCardsContract.Card.RAW_LEFT -> rb.add(currentCard.left)
11901196
else -> throw UnsupportedOperationException("Queue \"$column\" is unknown")
11911197
}
11921198
}

api/src/main/java/com/ichi2/anki/FlashCardsContract.kt

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,104 @@ public object FlashCardsContract {
688688
*/
689689
public const val ANSWER_PURE: String = "answer_pure"
690690

691+
/**
692+
* The stored Anki queue code for this card: how Anki decides whether the card can be shown.
693+
*
694+
* See [AnkiDroid's cards table docs](https://github.com/ankidroid/Anki-Android/wiki/Database-Structure/#cards).
695+
*
696+
* Unlike [TYPE], queue also includes temporary display states such as buried, suspended,
697+
* and preview.
698+
*
699+
* * `-3` = manually buried
700+
* * `-2` = sibling buried
701+
* * `-1` = suspended
702+
* * `0` = new
703+
* * `1` = learning
704+
* * `2` = review
705+
* * `3` = day-learning / relearning; next review is at least one day after the previous
706+
* review
707+
* * `4` = preview
708+
*
709+
* Negative values indicate cards that are not currently schedulable. Non-negative values
710+
* indicate active scheduler queues.
711+
*
712+
* Other values should be treated as unknown.
713+
*/
714+
public const val RAW_QUEUE: String = "queue"
715+
716+
/**
717+
* The stored Anki due value for this card.
718+
*
719+
* This is raw scheduler state, not a single normalized timestamp or day number.
720+
*
721+
* See [AnkiDroid's cards table docs](https://github.com/ankidroid/Anki-Android/wiki/Database-Structure/#cards).
722+
*
723+
* This value is state-dependent:
724+
*
725+
* * new queue: the order in which cards are to be studied; starts from 1
726+
* * learning / relearning queue: Unix timestamp in seconds
727+
* * review queue: the collection scheduler day number when the card is due for review.
728+
* This is not a calendar date; converting it to one requires collection
729+
* creation/day-cutoff metadata not currently exposed by this API.
730+
* * filtered deck: the position of the card inside the filtered deck
731+
*
732+
* Consumers must read this together with [RAW_QUEUE], and with [RAW_ORIGINAL_DUE] when
733+
* filtered-deck behavior matters.
734+
*/
735+
public const val RAW_DUE: String = "due"
736+
737+
/**
738+
* The stored original due value for this card.
739+
*
740+
* For cards in filtered decks, this stores the due value from before the card entered the
741+
* filtered deck. If the card is not currently in a filtered deck, this value is `0`.
742+
*
743+
* @see RAW_DUE for the state-dependent meaning of the current due value
744+
*/
745+
public const val RAW_ORIGINAL_DUE: String = "original_due"
746+
747+
/**
748+
* The stored Anki interval (`ivl`) for this card, in days.
749+
*
750+
* See [AnkiDroid's cards table docs](https://github.com/ankidroid/Anki-Android/wiki/Database-Structure/#cards).
751+
*
752+
* For review cards, this is the scheduled number of days between reviews. For relearning
753+
* cards, this retains the review interval that will apply when the card returns to the
754+
* review queue.
755+
*
756+
* Learning cards store `0`; their active learning due value is stored in [RAW_DUE].
757+
*/
758+
public const val INTERVAL: String = "interval"
759+
760+
/**
761+
* The stored SM-2 ease factor for this card.
762+
*
763+
* This factor helps determine the next review interval for cards using SM-2 scheduling.
764+
* It is not used by FSRS.
765+
*
766+
* This is an integer scaled by 10, so `2500` means `250%`.
767+
* New SM-2 cards typically start at `2500`.
768+
*/
769+
public const val RAW_SM2_FACTOR: String = "sm2_factor"
770+
771+
/**
772+
* The stored Anki `left` value for this card.
773+
*
774+
* This is relevant for learning and relearning cards.
775+
*
776+
* Anki uses this as an internal scheduler counter. The stored value is encoded as:
777+
*
778+
* * `left % 1000`: learning or relearning reps left before graduation
779+
* * `left / 1000`: reps that can still be completed before the day cutoff
780+
*
781+
* For example, `left = 2003` means 3 reps left before graduation, with 2 of
782+
* them still completable before the day cutoff.
783+
*
784+
* This provider exposes the backend value as-is, so consumers should not depend on a
785+
* normalized encoding.
786+
*/
787+
public const val RAW_LEFT: String = "left"
788+
691789
/**
692790
* The content:// style URI for cards. Can be used to search for cards or access specific cards.
693791
* For examples on how to use the URI for queries see the overview in [FlashCardsContract].

0 commit comments

Comments
 (0)