Skip to content

Commit

Permalink
Support richer set of flashcard ordering during review (#820)
Browse files Browse the repository at this point in the history
* Implemented WeightedRandomNumber

* Refactoring in preparation for additional functionality

* Implemented DeckOrder.EveryCardRandomDeck; not tested

* Enhanced card ordering implemented not tested

* Added test cases; plus a fix

* Implemented settings for new card/deck ordering features

* Updated to use text resources rather than hard coding strings

* Fixed bug that occurs if no existing settings file present

* Minor description update

* Comments; added user text for non-English languages

* pnpm format

* Added debug

* Remove debug and fix

* Fixed bug for PrevDeckComplete_Random setting

* Fixed test case

* Fixed issues that lint complained about

---------

Co-authored-by: Stephen Mwangi <mail@stephenmwangi.com>
  • Loading branch information
ronzulu and st3v3nmw authored Jan 1, 2024
1 parent 50e21ba commit 610c2ab
Show file tree
Hide file tree
Showing 21 changed files with 764 additions and 129 deletions.
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).

#### [Unreleased]

- Feat: Support richer set of flashcard ordering during review; e.g. random card from random deck [`#814`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/814)
- Bug fix Problem with nested list item's indentation [`#800`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/800)
- Bug fix Problem with nested list item's indentation [`#812`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/812)
- Bug Cloze Breaks When }} Encountered [`#799`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/799)
Expand Down
1 change: 0 additions & 1 deletion src/Deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export class Deck {
private getQuestionCardCountForCardListType(question: Question, cards: Card[]): number {
let result: number = 0;
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
if (Object.is(question, cards[i].question)) result++;
}
return result;
Expand Down
261 changes: 196 additions & 65 deletions src/DeckTreeIterator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,31 @@ import { Card } from "./Card";
import { CardListType, Deck } from "./Deck";
import { Question } from "./Question";
import { TopicPath } from "./TopicPath";
import { globalRandomNumberProvider } from "./util/RandomNumberProvider";

export enum CardListOrder {
NewFirst,
DueFirst,
Random,
import { WeightedRandomNumber, globalRandomNumberProvider } from "./util/RandomNumberProvider";

export enum CardOrder {
NewFirstSequential,
NewFirstRandom,
DueFirstSequential,
DueFirstRandom,
EveryCardRandomDeckAndCard,
}
export enum OrderMethod {
Sequential,
Random,
export enum DeckOrder {
PrevDeckComplete_Sequential,
PrevDeckComplete_Random,
}
export enum IteratorDeckSource {
UpdatedByIterator,
CloneBeforeUse,
}

export interface IIteratorOrder {
// Choose decks in sequential order, or randomly
deckOrder: OrderMethod;
// Within a deck this specifies the order the cards should be reviewed
// e.g. new first, going sequentially
cardOrder: CardOrder;

// Within a deck, choose: new cards first, due cards first, or randomly
cardListOrder: CardListOrder;

// Within a card list (i.e. either new or due), choose cards sequentially or randomly
cardOrder: OrderMethod;
// Choose decks in sequential order, or randomly
deckOrder: DeckOrder;
}

export interface IDeckTreeIterator {
Expand All @@ -46,70 +46,106 @@ class SingleDeckIterator {
preferredCardListType: CardListType;
cardIdx?: number;
cardListType?: CardListType;
weightedRandomNumber: WeightedRandomNumber;

get hasCurrentCard(): boolean {
return this.cardIdx != null;
}

get currentCard(): Card {
if (this.cardIdx == null) return null;
return this.deck.getCard(this.cardIdx, this.cardListType);
let result: Card = null;
if (this.cardIdx != null) result = this.deck.getCard(this.cardIdx, this.cardListType);
return result;
}

constructor(iteratorOrder: IIteratorOrder) {
this.iteratorOrder = iteratorOrder;
this.preferredCardListType =
this.iteratorOrder.cardListOrder == CardListOrder.DueFirst
? CardListType.DueCard
: CardListType.NewCard;
this.preferredCardListType = SingleDeckIterator.getCardListTypeForIterator(
this.iteratorOrder,
);
this.weightedRandomNumber = WeightedRandomNumber.create();
}

setDeck(deck: Deck): void {
this.deck = deck;
this.setCardListType(null);
}

private setCardListType(cardListType?: CardListType): void {
//
// 0 <= cardIndex < newFlashcards.length + dueFlashcards.length
//
setNewOrDueCardIdx(cardIndex: number): void {
let cardListType: CardListType = CardListType.NewCard;
let index: number = cardIndex;
if (cardIndex >= this.deck.newFlashcards.length) {
cardListType = CardListType.DueCard;
index = cardIndex - this.deck.newFlashcards.length;
}
this.setCardListType(cardListType, index);
}

private setCardListType(cardListType?: CardListType, cardIdx: number = null): void {
this.cardListType = cardListType;
this.cardIdx = null;
this.cardIdx = cardIdx;
}

nextCard(): boolean {
// First return cards in the preferred list
if (this.cardListType == null) {
this.setCardListType(this.preferredCardListType);
}
if (this.iteratorOrder.cardOrder == CardOrder.EveryCardRandomDeckAndCard) {
this.nextRandomCard();
} else {
// First return cards in the preferred list
if (this.cardListType == null) {
this.setCardListType(this.preferredCardListType);
}

if (!this.nextCardWithinList()) {
if (this.cardListType == this.preferredCardListType) {
// Nothing left in the preferred list, so try the non-preferred list type
this.setCardListType(Deck.otherListType(this.cardListType));
if (!this.nextCardWithinList()) {
this.setCardListType(null);
if (!this.nextCardWithinCurrentList()) {
if (this.cardListType == this.preferredCardListType) {
// Nothing left in the preferred list, so try the non-preferred list type
this.setCardListType(Deck.otherListType(this.cardListType));
if (!this.nextCardWithinCurrentList()) {
this.setCardListType(null);
}
} else {
this.cardIdx = null;
}
} else {
this.cardIdx = null;
}
}

return this.cardIdx != null;
}

private nextCardWithinList(): boolean {
let result: boolean = false;
private nextRandomCard(): void {
const newCount: number = this.deck.newFlashcards.length;
const dueCount: number = this.deck.dueFlashcards.length;
if (newCount + dueCount > 0) {
// Generate a random number such that the probability of picking an individual card is the same
// regardless of whether the card is in the new/due list, or which list has more cards
// I.e. we don't pick the new/due list first at 50/50 and then a random card within it
const weights: Partial<Record<CardListType, number>> = {};
if (newCount > 0) weights[CardListType.NewCard] = newCount;
if (dueCount > 0) weights[CardListType.DueCard] = dueCount;
const [cardListType, index] = this.weightedRandomNumber.getRandomValues(weights);
this.setCardListType(cardListType, index);
} else {
this.setCardListType(null);
}
}

private nextCardWithinCurrentList(): boolean {
const cardList: Card[] = this.deck.getCardListForCardType(this.cardListType);

// Delete the current card so we don't return it again
if (this.hasCurrentCard) {
this.deleteCurrentCard();
}
result = cardList.length > 0;
const result: boolean = cardList.length > 0;
if (result) {
switch (this.iteratorOrder.cardOrder) {
case OrderMethod.Sequential:
case CardOrder.DueFirstSequential:
case CardOrder.NewFirstSequential:
// We always pick the card with index 0
// Sequential retrieval occurs by the caller deleting the card at this index after it is used
this.cardIdx = 0;
break;

case OrderMethod.Random:
case CardOrder.DueFirstRandom:
case CardOrder.NewFirstRandom:
this.cardIdx = globalRandomNumberProvider.getInteger(0, cardList.length - 1);
break;
}
Expand Down Expand Up @@ -158,17 +194,32 @@ class SingleDeckIterator {
private ensureCurrentCard() {
if (this.cardIdx == null || this.cardListType == null) throw "no current card";
}

private static getCardListTypeForIterator(iteratorOrder: IIteratorOrder): CardListType | null {
let result: CardListType = null;
switch (iteratorOrder.cardOrder) {
case CardOrder.DueFirstRandom:
case CardOrder.DueFirstSequential:
result = CardListType.DueCard;
break;

case CardOrder.NewFirstRandom:
case CardOrder.NewFirstSequential:
result = CardListType.NewCard;
break;
}
return result;
}
}

export class DeckTreeIterator implements IDeckTreeIterator {
deckTree: Deck;
preferredCardListType: CardListType;
iteratorOrder: IIteratorOrder;
deckSource: IteratorDeckSource;
private iteratorOrder: IIteratorOrder;
private deckSource: IteratorDeckSource;

singleDeckIterator: SingleDeckIterator;
deckArray: Deck[];
deckIdx?: number;
private singleDeckIterator: SingleDeckIterator;
private deckArray: Deck[];
private deckIdx?: number;
private weightedRandomNumber: WeightedRandomNumber;

get hasCurrentCard(): boolean {
return this.deckIdx != null && this.singleDeckIterator.hasCurrentCard;
Expand All @@ -180,49 +231,120 @@ export class DeckTreeIterator implements IDeckTreeIterator {
}

get currentCard(): Card {
if (this.deckIdx == null || !this.singleDeckIterator.hasCurrentCard) return null;
return this.singleDeckIterator.currentCard;
let result: Card = null;
if (this.deckIdx != null && this.singleDeckIterator.hasCurrentCard)
result = this.singleDeckIterator.currentCard;
return result;
}

constructor(iteratorOrder: IIteratorOrder, deckSource: IteratorDeckSource) {
this.singleDeckIterator = new SingleDeckIterator(iteratorOrder);
this.iteratorOrder = iteratorOrder;
this.deckSource = deckSource;
this.weightedRandomNumber = WeightedRandomNumber.create();
}

setDeck(deck: Deck): void {
// We don't want to change the supplied deck, so first clone
if (this.deckSource == IteratorDeckSource.CloneBeforeUse) deck = deck.clone();

this.deckTree = deck;
this.deckArray = deck.toDeckArray();
this.deckArray = DeckTreeIterator.filterForDecksWithCards(deck.toDeckArray());
this.setDeckIdx(null);
}

private static filterForDecksWithCards(sourceArray: Deck[]): Deck[] {
const result: Deck[] = [];
for (let idx = 0; idx < sourceArray.length; idx++) {
const deck: Deck = sourceArray[idx];
const hasAnyCards = deck.getCardCount(CardListType.All, false) > 0;
if (hasAnyCards) {
result.push(deck);
}
}
return result;
}

private setDeckIdx(deckIdx?: number): void {
this.deckIdx = deckIdx;
if (deckIdx != null) this.singleDeckIterator.setDeck(this.deckArray[deckIdx]);
}

nextCard(): boolean {
let result: boolean = false;
if (this.deckIdx == null) {
this.setDeckIdx(0);

// Delete the current card so we don't return it again
if (this.hasCurrentCard) {
this.singleDeckIterator.deleteCurrentCard();
}
while (this.deckIdx < this.deckArray.length) {
if (this.singleDeckIterator.nextCard()) {
result = true;
break;

if (this.iteratorOrder.cardOrder == CardOrder.EveryCardRandomDeckAndCard) {
result = this.nextCard_EveryCardRandomDeck();
} else {
// If we are just starting, then depending on settings we want to either start from the first deck,
// or a random deck
if (this.deckIdx == null) {
this.chooseNextDeck(true);
}
this.deckIdx++;
if (this.deckIdx < this.deckArray.length) {
this.singleDeckIterator.setDeck(this.deckArray[this.deckIdx]);
while (this.deckIdx < this.deckArray.length) {
if (this.singleDeckIterator.nextCard()) {
result = true;
break;
}
this.chooseNextDeck(false);
}
}
if (!result) this.deckIdx = null;
return result;
}

private chooseNextDeck(firstTime: boolean): void {
switch (this.iteratorOrder.deckOrder) {
case DeckOrder.PrevDeckComplete_Sequential:
this.deckIdx = firstTime ? 0 : this.deckIdx + 1;
break;

case DeckOrder.PrevDeckComplete_Random: {
// Equal probability of picking any deck that has cards within
const weights: Record<number, number> = {};
let hasDeck: boolean = false;
for (let i = 0; i < this.deckArray.length; i++) {
if (this.deckArray[i].getCardCount(CardListType.All, false)) {
weights[i] = 1;
hasDeck = true;
}
}
if (hasDeck) {
const [deckIdx, _] = this.weightedRandomNumber.getRandomValues(weights);
this.deckIdx = deckIdx;
} else {
// Our signal that no deck with cards present
this.deckIdx = this.deckArray.length;
}
break;
}
}
if (this.deckIdx < this.deckArray.length) {
this.singleDeckIterator.setDeck(this.deckArray[this.deckIdx]);
}
}

private nextCard_EveryCardRandomDeck(): boolean {
// Make the chance of picking a specific deck proportional to the number of cards within
const weights: Record<number, number> = {};
for (let i = 0; i < this.deckArray.length; i++) {
const cardCount: number = this.deckArray[i].getCardCount(CardListType.All, false);
if (cardCount) {
weights[i] = cardCount;
}
}
if (Object.keys(weights).length == 0) return false;

const [deckIdx, cardIdx] = this.weightedRandomNumber.getRandomValues(weights);
this.setDeckIdx(deckIdx);
this.singleDeckIterator.setNewOrDueCardIdx(cardIdx);
return true;
}

deleteCurrentQuestion(): boolean {
this.singleDeckIterator.deleteCurrentQuestion();
return this.nextCard();
Expand All @@ -236,4 +358,13 @@ export class DeckTreeIterator implements IDeckTreeIterator {
moveCurrentCardToEndOfList(): void {
this.singleDeckIterator.moveCurrentCardToEndOfList();
}

private removeCurrentDeckIfEmpty(): void {
if (this.currentDeck.getCardCount(CardListType.All, false) == 0) {
this.deckArray.splice(this.deckIdx, 1);

// There is no change to deckIdx, but this now is a different deck
if (this.deckIdx < this.deckArray.length) this.setDeckIdx(this.deckIdx);
}
}
}
Loading

0 comments on commit 610c2ab

Please sign in to comment.