diff --git a/docs/changelog.md b/docs/changelog.md index 8a8ace6d..66d8c2fb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,10 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### [1.11.2](https://github.com/st3v3nmw/obsidian-spaced-repetition/compare/1.11.1...1.11.2) +- Bug 495 Obsidian SRS ignores more than one tag https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/834 +- Fixes [BUG] Malformed card text during review, when multi-line card has space on Q/A separator line https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/853 +- Fixes bug #815 HTML review comment deactivates block identifier https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/815 +- Implementation of some little UI improvements https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/869 - Add polish translation [`#889`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/889) - Add support for Italian [`#886`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/886) - update dependencies [`#892`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/892) diff --git a/docs/en/flashcards.md b/docs/en/flashcards.md index a97aa425..afabfdc2 100644 --- a/docs/en/flashcards.md +++ b/docs/en/flashcards.md @@ -94,6 +94,60 @@ The green and blue counts on the right of each deck name represent due and new c Note that `#flashcards` will match nested tags like `#flashcards/subdeck/subdeck`. +#### Multiple Tags Within a Single File + +A single file can contain cards for multiple different decks. + +This is possible because a tag pertains to all subsequent cards in a file until any subsequent tag. + +For example: + +```markdown +#flashcards/deckA +Question1 (in deckA)::Answer1 +Question2 (also in deckA)::Answer2 +Question3 (also in deckA)::Answer3 + +#flashcards/deckB +Question4 (in deckB)::Answer4 +Question5 (also in deckB)::Answer5 + +#flashcards/deckC +Question6 (in deckC)::Answer6 +``` + +#### A Single Card Within Multiple Decks + +Usually the content of a card is only relevant to a single deck. However, sometimes content doesn't fall neatly into a single deck of the hierarchy. + +In these cases, a card can be tagged as being part of multiple decks. The following card is specified as being in the three different decks listed. + +```markdown +#flashcards/language/words #flashcards/trivia #flashcards/learned-from-tv +A group of cats is called a::clowder +``` + +Note that as shown in the above example, all tags must be placed on the same line, separated by spaces. + +#### Question Specific Tags + +A tag that is present at the start of the first line of a card is "question specific", and applies only to that card. + +For example: + +```markdown +#flashcards/deckA +Question1 (in deckA)::Answer1 +Question2 (also in deckA)::Answer2 +Question3 (also in deckA)::Answer3 + +#flashcards/deckB Question4 (in deckB)::Answer4 + +Question6 (in deckA)::Answer6 +``` + +Here `Question6` will be part of `deckA` and not `deckB` as `deckB` is specific to `Question4` only. + ### Using Folder Structure The plugin will automatically search for folders that contain flashcards & use their paths to create decks & sub-decks i.e. `Folder/sub-folder/sub-sub-folder` ⇔ `Deck/sub-deck/sub-sub-deck`. diff --git a/src/Deck.ts b/src/Deck.ts index 66f06d26..c7f53b1c 100644 --- a/src/Deck.ts +++ b/src/Deck.ts @@ -2,7 +2,7 @@ import { Card } from "./Card"; import { FlashcardReviewMode } from "./FlashcardReviewSequencer"; import { Question } from "./Question"; import { IQuestionPostponementList } from "./QuestionPostponementList"; -import { TopicPath } from "./TopicPath"; +import { TopicPath, TopicPathList } from "./TopicPath"; export enum CardListType { NewCard, @@ -10,6 +10,12 @@ export enum CardListType { All, } +// +// The same card can be added to multiple decks e.g. +// #flashcards/language/words +// #flashcards/trivia +// To simplify certain functions (e.g. getDistinctCardCount), we explicitly use the same card object (and not a copy) +// export class Deck { public deckName: string; public newFlashcards: Card[]; @@ -40,6 +46,41 @@ export class Deck { return result; } + public getDistinctCardCount(cardListType: CardListType, includeSubdeckCounts: boolean): number { + const cardList: Card[] = this.getFlattenedCardArray(cardListType, includeSubdeckCounts); + + // The following selects distinct cards from cardList (based on reference equality) + const distinctCardSet = new Set(cardList); + // console.log(`getDistinctCardCount: ${this.deckName} ${distinctCardSet.size} ${this.getCardCount(cardListType, includeSubdeckCounts)}`); + return distinctCardSet.size; + } + + public getFlattenedCardArray( + cardListType: CardListType, + includeSubdeckCounts: boolean, + ): Card[] { + let result: Card[] = [] as Card[]; + switch (cardListType) { + case CardListType.NewCard: + result = this.newFlashcards; + break; + case CardListType.DueCard: + result = this.dueFlashcards; + break; + case CardListType.All: + result = this.newFlashcards.concat(this.dueFlashcards); + } + + if (includeSubdeckCounts) { + for (const subdeck of this.subdecks) { + result = result.concat( + subdeck.getFlattenedCardArray(cardListType, includeSubdeckCounts), + ); + } + } + return result; + } + // // Returns a count of the number of this question's cards are present in this deck. // (The returned value would be <= question.cards.length) @@ -67,6 +108,10 @@ export class Deck { return this.parent == null; } + getDeckByTopicTag(tag: string): Deck { + return this.getDeck(TopicPath.getTopicPathFromTag(tag)); + } + getDeck(topicPath: TopicPath): Deck { return this._getOrCreateDeck(topicPath, false); } @@ -100,6 +145,8 @@ export class Deck { const list: string[] = []; // eslint-disable-next-line @typescript-eslint/no-this-alias let deck: Deck = this; + // The root deck may have a dummy deck name, which we don't want + // So we first check that this isn't the root deck while (!deck.isRootDeck) { list.push(deck.deckName); deck = deck.parent; @@ -125,17 +172,64 @@ export class Deck { return cardListType == CardListType.DueCard ? this.dueFlashcards : this.newFlashcards; } - appendCard(topicPath: TopicPath, cardObj: Card): void { + appendCard(topicPathList: TopicPathList, cardObj: Card): void { + if (topicPathList.list.length == 0) { + this.appendCardToRootDeck(cardObj); + } else { + // We explicitly are adding the same card object to each of the specified decks + // This is required by getDistinctCardCount() + for (const topicPath of topicPathList.list) { + this.appendCard_SingleTopic(topicPath, cardObj); + } + } + } + + appendCardToRootDeck(cardObj: Card): void { + this.appendCard_SingleTopic(TopicPath.emptyPath, cardObj); + } + + appendCard_SingleTopic(topicPath: TopicPath, cardObj: Card): void { const deck: Deck = this.getOrCreateDeck(topicPath); const cardList: Card[] = deck.getCardListForCardType(cardObj.cardListType); cardList.push(cardObj); } - deleteCard(card: Card): void { - const cardList: Card[] = this.getCardListForCardType(card.cardListType); - const idx = cardList.indexOf(card); - if (idx != -1) cardList.splice(idx, 1); + // + // The question lists all the topics in which this card is included. + // The topics are relative to the base deck, and this method must be called on that deck + // + deleteQuestionFromAllDecks(question: Question, exceptionIfMissing: boolean): void { + for (const card of question.cards) { + this.deleteCardFromAllDecks(card, exceptionIfMissing); + } + } + + deleteQuestion(question: Question, exceptionIfMissing: boolean): void { + for (const card of question.cards) { + this.deleteCardFromThisDeck(card, exceptionIfMissing); + } + } + + // + // The card's question lists all the topics in which this card is included. + // The topics are relative to the base deck, and this method must be called on that deck + // + deleteCardFromAllDecks(card: Card, exceptionIfMissing: boolean): void { + for (const topicPath of card.question.topicPathList.list) { + const deck: Deck = this.getDeck(topicPath); + deck.deleteCardFromThisDeck(card, exceptionIfMissing); + } + } + + deleteCardFromThisDeck(card: Card, exceptionIfMissing: boolean): void { + const newIdx = this.newFlashcards.indexOf(card); + if (newIdx != -1) this.newFlashcards.splice(newIdx, 1); + const dueIdx = this.dueFlashcards.indexOf(card); + if (dueIdx != -1) this.dueFlashcards.splice(dueIdx, 1); + if (newIdx == -1 && dueIdx == -1 && exceptionIfMissing) { + throw `deleteCardFromThisDeck: Card: ${card.front} not found in deck: ${this.deckName}`; + } } deleteCardAtIndex(index: number, cardListType: CardListType): void { @@ -167,9 +261,9 @@ export class Deck { } } - debugLogToConsole(desc: string = null) { + debugLogToConsole(desc: string = null, indent: number = 0) { let str: string = desc != null ? `${desc}: ` : ""; - console.log((str += this.toString())); + console.log((str += this.toString(indent))); } toString(indent: number = 0): string { diff --git a/src/DeckTreeIterator.ts b/src/DeckTreeIterator.ts index 4aef6f0a..1c5d0ae4 100644 --- a/src/DeckTreeIterator.ts +++ b/src/DeckTreeIterator.ts @@ -15,10 +15,6 @@ export enum DeckOrder { PrevDeckComplete_Sequential, PrevDeckComplete_Random, } -export enum IteratorDeckSource { - UpdatedByIterator, - CloneBeforeUse, -} export interface IIteratorOrder { // Within a deck this specifies the order the cards should be reviewed @@ -33,9 +29,10 @@ export interface IDeckTreeIterator { get currentDeck(): Deck; get currentCard(): Card; get hasCurrentCard(): boolean; - setDeck(deck: Deck): void; - deleteCurrentCard(): boolean; - deleteCurrentQuestion(): boolean; + setBaseDeck(baseDeck: Deck): void; + setIteratorTopicPath(topicPath: TopicPath): void; + deleteCurrentCardFromAllDecks(): boolean; + deleteCurrentQuestionFromAllDecks(): boolean; moveCurrentCardToEndOfList(): void; nextCard(): boolean; } @@ -153,45 +150,21 @@ class SingleDeckIterator { return result; } - deleteCurrentQuestion(): void { - this.ensureCurrentCard(); - const q: Question = this.currentCard.question; - - // A question could have some cards in the new list and some in the due list - this.deleteQuestionFromList(q, CardListType.NewCard); - this.deleteQuestionFromList(q, CardListType.DueCard); - - this.setNoCurrentCard(); - } - - private deleteQuestionFromList(q: Question, cardListType: CardListType): void { - const cards: Card[] = this.deck.getCardListForCardType(cardListType); - for (let i = cards.length - 1; i >= 0; i--) { - if (Object.is(q, cards[i].question)) this.deck.deleteCardAtIndex(i, cardListType); - } - } - - deleteCurrentCard(): void { - this.ensureCurrentCard(); - this.deck.deleteCardAtIndex(this.cardIdx, this.cardListType); - this.setNoCurrentCard(); - } - moveCurrentCardToEndOfList(): void { this.ensureCurrentCard(); const cardList: Card[] = this.deck.getCardListForCardType(this.cardListType); if (cardList.length <= 1) return; const card = this.currentCard; this.deck.deleteCardAtIndex(this.cardIdx, this.cardListType); - this.deck.appendCard(TopicPath.emptyPath, card); + this.deck.appendCardToRootDeck(card); this.setNoCurrentCard(); } - private setNoCurrentCard() { + setNoCurrentCard() { this.cardIdx = null; } - private ensureCurrentCard() { + ensureCurrentCard() { if (this.cardIdx == null || this.cardListType == null) throw "no current card"; } @@ -212,19 +185,41 @@ class SingleDeckIterator { } } +// +// Note that this iterator is destructive over the deck tree supplied to setBaseDeck() +// The caller is required to first make a clone if this behavior is unwanted. +// +// Handling of multi-deck cards (implemented for https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/495): +// A "multi-deck card" is a card that is present in multiple decks, e.g. the following cat question is present in +// two separate decks. +// +// #flashcards/language/words #flashcards/trivia/interesting +// A group of cats is called a::clowder +// +// 1. Whilst iterating, any multi-deck card is only returned once. +// 2. All copies are removed from the deck tree supplied to setBaseDeck() +// export class DeckTreeIterator implements IDeckTreeIterator { private iteratorOrder: IIteratorOrder; - private deckSource: IteratorDeckSource; private singleDeckIterator: SingleDeckIterator; + private baseDeckTree: Deck; + + // The subset of baseDeckTree over which we are iterating + // Each item is treated as a single deck, i.e. any subdecks are ignored private deckArray: Deck[]; private deckIdx?: number; + private weightedRandomNumber: WeightedRandomNumber; get hasCurrentCard(): boolean { return this.deckIdx != null && this.singleDeckIterator.hasCurrentCard; } + get currentTopicPath(): TopicPath { + return this.currentDeck?.getTopicPath(); + } + get currentDeck(): Deck { if (this.deckIdx == null) return null; return this.deckArray[this.deckIdx]; @@ -237,18 +232,25 @@ export class DeckTreeIterator implements IDeckTreeIterator { return result; } - constructor(iteratorOrder: IIteratorOrder, deckSource: IteratorDeckSource) { + get currentQuestion(): Question { + return this.currentCard?.question; + } + + constructor(iteratorOrder: IIteratorOrder, baseDeckTree: Deck) { this.singleDeckIterator = new SingleDeckIterator(iteratorOrder); this.iteratorOrder = iteratorOrder; - this.deckSource = deckSource; this.weightedRandomNumber = WeightedRandomNumber.create(); + this.setBaseDeck(baseDeckTree); } - setDeck(deck: Deck): void { - // We don't want to change the supplied deck, so first clone - if (this.deckSource == IteratorDeckSource.CloneBeforeUse) deck = deck.clone(); + setBaseDeck(baseDeck: Deck): void { + this.baseDeckTree = baseDeck; + this.singleDeckIterator.setNoCurrentCard(); + } - this.deckArray = DeckTreeIterator.filterForDecksWithCards(deck.toDeckArray()); + setIteratorTopicPath(topicPath: TopicPath): void { + const iteratorDeck: Deck = this.baseDeckTree.getDeck(topicPath); + this.deckArray = DeckTreeIterator.filterForDecksWithCards(iteratorDeck.toDeckArray()); this.setDeckIdx(null); } @@ -274,7 +276,7 @@ export class DeckTreeIterator implements IDeckTreeIterator { // Delete the current card so we don't return it again if (this.hasCurrentCard) { - this.singleDeckIterator.deleteCurrentCard(); + this.baseDeckTree.deleteCardFromAllDecks(this.currentCard, true); } if (this.iteratorOrder.cardOrder == CardOrder.EveryCardRandomDeckAndCard) { @@ -345,13 +347,20 @@ export class DeckTreeIterator implements IDeckTreeIterator { return true; } - deleteCurrentQuestion(): boolean { - this.singleDeckIterator.deleteCurrentQuestion(); + deleteCurrentQuestionFromAllDecks(): boolean { + this.singleDeckIterator.ensureCurrentCard(); + + // Delete every card of this question from every deck specified for the question + // Note that not every card will necessarily be present, so we pass false to the following + this.baseDeckTree.deleteQuestionFromAllDecks(this.currentQuestion, false); + this.singleDeckIterator.setNoCurrentCard(); return this.nextCard(); } - deleteCurrentCard(): boolean { - this.singleDeckIterator.deleteCurrentCard(); + deleteCurrentCardFromAllDecks(): boolean { + this.singleDeckIterator.ensureCurrentCard(); + this.baseDeckTree.deleteCardFromAllDecks(this.currentCard, true); + this.singleDeckIterator.setNoCurrentCard(); return this.nextCard(); } diff --git a/src/DeckTreeStatsCalculator.ts b/src/DeckTreeStatsCalculator.ts index 53387194..2375cd71 100644 --- a/src/DeckTreeStatsCalculator.ts +++ b/src/DeckTreeStatsCalculator.ts @@ -5,11 +5,11 @@ import { DeckTreeIterator, IDeckTreeIterator, IIteratorOrder, - IteratorDeckSource, } from "./DeckTreeIterator"; import { Card } from "./Card"; import { Stats } from "./stats"; import { CardScheduleInfo } from "./CardSchedule"; +import { TopicPath } from "./TopicPath"; export class DeckTreeStatsCalculator { private deckTree: Deck; @@ -20,12 +20,10 @@ export class DeckTreeStatsCalculator { deckOrder: DeckOrder.PrevDeckComplete_Sequential, cardOrder: CardOrder.DueFirstSequential, }; - const iterator: IDeckTreeIterator = new DeckTreeIterator( - iteratorOrder, - IteratorDeckSource.CloneBeforeUse, - ); + // Iteration is a destructive operation on the supplied tree, so we first take a copy + const iterator: IDeckTreeIterator = new DeckTreeIterator(iteratorOrder, deckTree.clone()); const result = new Stats(); - iterator.setDeck(deckTree); + iterator.setIteratorTopicPath(TopicPath.emptyPath); while (iterator.nextCard()) { const card: Card = iterator.currentCard; if (card.hasSchedule) { diff --git a/src/FlashcardReviewSequencer.ts b/src/FlashcardReviewSequencer.ts index 072e43f8..d4c9eda3 100644 --- a/src/FlashcardReviewSequencer.ts +++ b/src/FlashcardReviewSequencer.ts @@ -44,8 +44,12 @@ export enum FlashcardReviewMode { } export class FlashcardReviewSequencer implements IFlashcardReviewSequencer { + // We need the original deck tree so that we can still provide the total cards in each deck private _originalDeckTree: Deck; + + // This is set by the caller, and must have the same deck hierarchy as originalDeckTree. private remainingDeckTree: Deck; + private reviewMode: FlashcardReviewMode; private cardSequencer: IDeckTreeIterator; private settings: SRSettings; @@ -86,15 +90,17 @@ export class FlashcardReviewSequencer implements IFlashcardReviewSequencer { return this.currentQuestion.note; } + // originalDeckTree isn't modified by the review process + // Only remainingDeckTree setDeckTree(originalDeckTree: Deck, remainingDeckTree: Deck): void { + this.cardSequencer.setBaseDeck(remainingDeckTree); this._originalDeckTree = originalDeckTree; this.remainingDeckTree = remainingDeckTree; this.setCurrentDeck(TopicPath.emptyPath); } setCurrentDeck(topicPath: TopicPath): void { - const deck: Deck = this.remainingDeckTree.getDeck(topicPath); - this.cardSequencer.setDeck(deck); + this.cardSequencer.setIteratorTopicPath(topicPath); this.cardSequencer.nextCard(); } @@ -105,19 +111,19 @@ export class FlashcardReviewSequencer implements IFlashcardReviewSequencer { getDeckStats(topicPath: TopicPath): DeckStats { const totalCount: number = this._originalDeckTree .getDeck(topicPath) - .getCardCount(CardListType.All, true); + .getDistinctCardCount(CardListType.All, true); const remainingDeck: Deck = this.remainingDeckTree.getDeck(topicPath); - const newCount: number = remainingDeck.getCardCount(CardListType.NewCard, true); - const dueCount: number = remainingDeck.getCardCount(CardListType.DueCard, true); + const newCount: number = remainingDeck.getDistinctCardCount(CardListType.NewCard, true); + const dueCount: number = remainingDeck.getDistinctCardCount(CardListType.DueCard, true); return new DeckStats(dueCount, newCount, totalCount); } skipCurrentCard(): void { - this.cardSequencer.deleteCurrentQuestion(); + this.cardSequencer.deleteCurrentQuestionFromAllDecks(); } private deleteCurrentCard(): void { - this.cardSequencer.deleteCurrentCard(); + this.cardSequencer.deleteCurrentCardFromAllDecks(); } async processReview(response: ReviewResponse): Promise { @@ -145,7 +151,7 @@ export class FlashcardReviewSequencer implements IFlashcardReviewSequencer { } else { if (this.settings.burySiblingCards) { await this.burySiblingCards(); - this.cardSequencer.deleteCurrentQuestion(); + this.cardSequencer.deleteCurrentQuestionFromAllDecks(); } else { this.deleteCurrentCard(); } diff --git a/src/Note.ts b/src/Note.ts index 46ae5cb8..15466939 100644 --- a/src/Note.ts +++ b/src/Note.ts @@ -24,7 +24,7 @@ export class Note { appendCardsToDeck(deck: Deck): void { for (const question of this.questionList) { for (const card of question.cards) { - deck.appendCard(question.topicPath, card); + deck.appendCard(question.topicPathList, card); } } } @@ -33,7 +33,9 @@ export class Note { let str: string = `Note: ${desc}: ${this.questionList.length} questions\r\n`; for (let i = 0; i < this.questionList.length; i++) { const q: Question = this.questionList[i]; - str += `[${i}]: ${q.questionType}: ${q.lineNo}: ${q.topicPath?.path}: ${q.questionText.original}\r\n`; + str += `[${i}]: ${q.questionType}: ${q.lineNo}: ${q.topicPathList?.format("|")}: ${ + q.questionText.original + }\r\n`; } console.debug(str); } diff --git a/src/NoteFileLoader.ts b/src/NoteFileLoader.ts index 83ed69e9..252e863b 100644 --- a/src/NoteFileLoader.ts +++ b/src/NoteFileLoader.ts @@ -16,14 +16,16 @@ export class NoteFileLoader { this.settings = settings; } - async load(noteFile: ISRFile, noteTopicPath: TopicPath): Promise { + async load(noteFile: ISRFile, folderTopicPath: TopicPath): Promise { this.noteFile = noteFile; const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings); + const onlyKeepQuestionsWithTopicPath: boolean = true; const questionList: Question[] = await questionParser.createQuestionList( noteFile, - noteTopicPath, + folderTopicPath, + onlyKeepQuestionsWithTopicPath, ); const result: Note = new Note(noteFile, questionList); diff --git a/src/NoteParser.ts b/src/NoteParser.ts index 01a00ab6..15b7fdbe 100644 --- a/src/NoteParser.ts +++ b/src/NoteParser.ts @@ -14,7 +14,7 @@ export class NoteParser { async parse(noteFile: ISRFile, folderTopicPath: TopicPath): Promise { const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings); - const questions = await questionParser.createQuestionList(noteFile, folderTopicPath); + const questions = await questionParser.createQuestionList(noteFile, folderTopicPath, true); const result: Note = new Note(noteFile, questions); return result; diff --git a/src/NoteQuestionParser.ts b/src/NoteQuestionParser.ts index fdd63bc2..6e75945e 100644 --- a/src/NoteQuestionParser.ts +++ b/src/NoteQuestionParser.ts @@ -1,56 +1,84 @@ +import { TagCache } from "obsidian"; import { Card } from "./Card"; import { CardScheduleInfo, NoteCardScheduleParser } from "./CardSchedule"; -import { parse } from "./parser"; -import { CardType, Question } from "./Question"; +import { parseEx, ParsedQuestionInfo } from "./parser"; +import { Question, QuestionText } from "./Question"; import { CardFrontBack, CardFrontBackUtil } from "./QuestionType"; -import { SRSettings } from "./settings"; +import { SRSettings, SettingsUtil } from "./settings"; import { ISRFile } from "./SRFile"; -import { TopicPath } from "./TopicPath"; - -export class ParsedQuestionInfo { - cardType: CardType; - cardText: string; - lineNo: number; - - constructor(cardType: CardType, cardText: string, lineNo: number) { - this.cardType = cardType; - this.cardText = cardText; - this.lineNo = lineNo; - } -} +import { TopicPath, TopicPathList } from "./TopicPath"; +import { extractFrontmatter, splitTextIntoLineArray } from "./util/utils"; export class NoteQuestionParser { settings: SRSettings; noteFile: ISRFile; - noteTopicPath: TopicPath; + folderTopicPath: TopicPath; noteText: string; + noteLines: string[]; + tagCacheList: TagCache[]; + frontmatterTopicPathList: TopicPathList; + contentTopicPathInfo: TopicPathList[]; + questionList: Question[]; constructor(settings: SRSettings) { this.settings = settings; } - async createQuestionList(noteFile: ISRFile, folderTopicPath: TopicPath): Promise { + async createQuestionList( + noteFile: ISRFile, + folderTopicPath: TopicPath, + onlyKeepQuestionsWithTopicPath: boolean, + ): Promise { this.noteFile = noteFile; const noteText: string = await noteFile.read(); - let noteTopicPath: TopicPath; - if (this.settings.convertFoldersToDecks) { - noteTopicPath = folderTopicPath; + + // Get the list of tags, and analyse for the topic list + const tagCacheList: TagCache[] = noteFile.getAllTagsFromText(); + + const hasTopicPaths = + tagCacheList.some((item) => SettingsUtil.isFlashcardTag(this.settings, item.tag)) || + folderTopicPath.hasPath; + if (hasTopicPaths) { + // The following analysis can require fair computation. + // There is no point doing it if there aren't any topic paths + + // Create the question list + this.questionList = this.doCreateQuestionList( + noteText, + folderTopicPath, + this.tagCacheList, + ); + + // For each question, determine it's TopicPathList + [this.frontmatterTopicPathList, this.contentTopicPathInfo] = + this.analyseTagCacheList(tagCacheList); + for (const question of this.questionList) { + question.topicPathList = this.determineQuestionTopicPathList(question); + } + + // Now only keep questions that have a topic list + if (onlyKeepQuestionsWithTopicPath) { + this.questionList = this.questionList.filter((q) => q.topicPathList); + } } else { - const tagList: string[] = noteFile.getAllTags(); - noteTopicPath = this.determineTopicPathFromTags(tagList); + this.questionList = [] as Question[]; } - const result: Question[] = this.doCreateQuestionList(noteText, noteTopicPath); - return result; + return this.questionList; } - private doCreateQuestionList(noteText: string, noteTopicPath: TopicPath): Question[] { + private doCreateQuestionList( + noteText: string, + folderTopicPath: TopicPath, + tagCacheList: TagCache[], + ): Question[] { this.noteText = noteText; - this.noteTopicPath = noteTopicPath; + this.noteLines = splitTextIntoLineArray(noteText); + this.folderTopicPath = folderTopicPath; + this.tagCacheList = tagCacheList; const result: Question[] = []; - const parsedQuestionInfoList: [CardType, string, number][] = this.parseQuestions(); - for (const t of parsedQuestionInfoList) { - const parsedQuestionInfo: ParsedQuestionInfo = new ParsedQuestionInfo(t[0], t[1], t[2]); + const parsedQuestionInfoList: ParsedQuestionInfo[] = this.parseQuestions(); + for (const parsedQuestionInfo of parsedQuestionInfoList) { const question: Question = this.createQuestionObject(parsedQuestionInfo); // Each rawCardText can turn into multiple CardFrontBack's (e.g. CardType.Cloze, CardType.SingleLineReversed) @@ -79,9 +107,9 @@ export class NoteQuestionParser { return result; } - private parseQuestions(): [CardType, string, number][] { + private parseQuestions(): ParsedQuestionInfo[] { const settings: SRSettings = this.settings; - const result: [CardType, string, number][] = parse( + const result: ParsedQuestionInfo[] = parseEx( this.noteText, settings.singleLineCardSeparator, settings.singleLineReversedCardSeparator, @@ -95,15 +123,13 @@ export class NoteQuestionParser { } private createQuestionObject(parsedQuestionInfo: ParsedQuestionInfo): Question { - const { cardType, cardText, lineNo } = parsedQuestionInfo; - - const questionContext: string[] = this.noteFile.getQuestionContext(lineNo); + const questionContext: string[] = this.noteFile.getQuestionContext( + parsedQuestionInfo.firstLineNum, + ); const result = Question.Create( this.settings, - cardType, - this.noteTopicPath, - cardText, - lineNo, + parsedQuestionInfo, + null, // We haven't worked out the TopicPathList yet questionContext, ); return result; @@ -135,13 +161,118 @@ export class NoteQuestionParser { return siblings; } - private determineTopicPathFromTags(tagList: string[]): TopicPath { - let result: TopicPath = TopicPath.emptyPath; - outer: for (const tagToReview of this.settings.flashcardTags) { - for (const tag of tagList) { - if (tag === tagToReview || tag.startsWith(tagToReview + "/")) { - result = TopicPath.getTopicPathFromTag(tag); - break outer; + // + // Given the complete list of tags within a note: + // 1. Only keep tags that are specified in the user settings as flashcardTags + // 2. Filter out tags that are question specific + // (these will be parsed separately by class QuestionText) + // 3. Combine all tags present logically grouped together into a single entry + // - All tags present on the same line grouped together + // - All tags within frontmatter grouped together (note that multiple tags + // within frontmatter appear on separate lines) + // + private analyseTagCacheList(tagCacheList: TagCache[]): [TopicPathList, TopicPathList[]] { + let frontmatterTopicPathList: TopicPathList = null; + const contentTopicPathList: TopicPathList[] = [] as TopicPathList[]; + + // Only keep tags that are: + // 1. specified in the user settings as flashcardTags, and + // 2. is not question specific (determined by line number) + const filteredTagCacheList: TagCache[] = tagCacheList.filter( + (item) => + SettingsUtil.isFlashcardTag(this.settings, item.tag) && + this.questionList.every( + (q) => !q.parsedQuestionInfo.isQuestionLineNum(item.position.start.line), + ), + ); + let frontmatterLineCount: number = null; + if (filteredTagCacheList.length > 0) { + // To simplify analysis, ensure that the supplied list is ordered by line number + tagCacheList.sort((a, b) => a.position.start.line - b.position.start.line); + + // Treat the frontmatter slightly differently (all tags grouped together even if on separate lines) + const [frontmatter, _] = extractFrontmatter(this.noteText); + if (frontmatter) { + frontmatterLineCount = splitTextIntoLineArray(frontmatter).length; + const frontmatterTagCacheList = filteredTagCacheList.filter( + (item) => item.position.start.line < frontmatterLineCount, + ); + + // Doesn't matter what line number we specify, as long as it's less than frontmatterLineCount + if (frontmatterTagCacheList.length > 0) + frontmatterTopicPathList = this.createTopicPathList(tagCacheList, 0); + } + } + // + const contentStartLineNum: number = frontmatterLineCount > 0 ? frontmatterLineCount + 1 : 0; + const contentTagCacheList: TagCache[] = filteredTagCacheList.filter( + (item) => item.position.start.line >= contentStartLineNum, + ); + + let list: TagCache[] = [] as TagCache[]; + for (const t of contentTagCacheList) { + if (list.length != 0) { + const startLineNum: number = list[0].position.start.line; + if (startLineNum != t.position.start.line) { + contentTopicPathList.push(this.createTopicPathList(list, startLineNum)); + list = [] as TagCache[]; + } + } + list.push(t); + } + if (list.length > 0) { + const startLineNum: number = list[0].position.start.line; + contentTopicPathList.push(this.createTopicPathList(list, startLineNum)); + } + + return [frontmatterTopicPathList, contentTopicPathList]; + } + + private createTopicPathList(tagCacheList: TagCache[], lineNum: number): TopicPathList { + const list: TopicPath[] = [] as TopicPath[]; + for (const tagCache of tagCacheList) { + list.push(TopicPath.getTopicPathFromTag(tagCache.tag)); + } + return new TopicPathList(list, lineNum); + } + + // + // A question can be associated with multiple topics (hence returning TopicPathList and not just TopicPath). + // + // If the question has an associated question specific TopicPath, then that is returned. + // + // Else the first TopicPathList prior to the question (in the order present in the file) is returned. + // That could be either the tags within the note's frontmatter, or tags on lines within the note's content. + // + private determineQuestionTopicPathList(question: Question): TopicPathList { + let result: TopicPathList; + if (this.settings.convertFoldersToDecks) { + result = new TopicPathList([this.folderTopicPath]); + } else { + // If present, the question specific TopicPath takes precedence over everything else + const questionText: QuestionText = question.questionText; + if (questionText.topicPathWithWs) + result = new TopicPathList( + [questionText.topicPathWithWs.topicPath], + question.parsedQuestionInfo.firstLineNum, + ); + else { + // By default we start off with any TopicPathList present in the frontmatter + result = this.frontmatterTopicPathList; + + // Find the last TopicPathList prior to the question (in the order present in the file) + for (let i = this.contentTopicPathInfo.length - 1; i >= 0; i--) { + const info: TopicPathList = this.contentTopicPathInfo[i]; + if (info.lineNum < question.parsedQuestionInfo.firstLineNum) { + result = info; + break; + } + } + + // For backward compatibility with functionality pre https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/495: + // if nothing matched, then use the first one + if (!result && this.contentTopicPathInfo.length > 0) { + result = this.contentTopicPathInfo[0]; } } } diff --git a/src/Question.ts b/src/Question.ts index 1e181381..16b1b27a 100644 --- a/src/Question.ts +++ b/src/Question.ts @@ -7,8 +7,9 @@ import { SR_HTML_COMMENT_END, } from "./constants"; import { Note } from "./Note"; +import { ParsedQuestionInfo } from "./parser"; import { SRSettings } from "./settings"; -import { TopicPath, TopicPathWithWs } from "./TopicPath"; +import { TopicPath, TopicPathList, TopicPathWithWs } from "./TopicPath"; import { MultiLineTextFinder } from "./util/MultiLineTextFinder"; import { cyrb53, stringTrimStart } from "./util/utils"; @@ -177,15 +178,21 @@ export class QuestionText { export class Question { note: Note; - questionType: CardType; - topicPath: TopicPath; + parsedQuestionInfo: ParsedQuestionInfo; + topicPathList: TopicPathList; questionText: QuestionText; - lineNo: number; hasEditLaterTag: boolean; questionContext: string[]; cards: Card[]; hasChanged: boolean; + get questionType(): CardType { + return this.parsedQuestionInfo.cardType; + } + get lineNo(): number { + return this.parsedQuestionInfo.firstLineNum; + } + constructor(init?: Partial) { Object.assign(this, init); } @@ -278,27 +285,28 @@ export class Question { this.hasChanged = false; } + formatTopicPathList(): string { + return this.topicPathList.format("|"); + } + static Create( settings: SRSettings, - questionType: CardType, - noteTopicPath: TopicPath, - originalText: string, - lineNo: number, + parsedQuestionInfo: ParsedQuestionInfo, + noteTopicPathList: TopicPathList, context: string[], ): Question { - const hasEditLaterTag = originalText.includes(settings.editLaterTag); - const questionText: QuestionText = QuestionText.create(originalText, settings); + const hasEditLaterTag = parsedQuestionInfo.text.includes(settings.editLaterTag); + const questionText: QuestionText = QuestionText.create(parsedQuestionInfo.text, settings); - let topicPath: TopicPath = noteTopicPath; + let topicPathList: TopicPathList = noteTopicPathList; if (questionText.topicPathWithWs) { - topicPath = questionText.topicPathWithWs.topicPath; + topicPathList = new TopicPathList([questionText.topicPathWithWs.topicPath]); } const result: Question = new Question({ - questionType, - topicPath, + parsedQuestionInfo, + topicPathList, questionText, - lineNo, hasEditLaterTag, questionContext: context, cards: null, diff --git a/src/SRFile.ts b/src/SRFile.ts index 46378070..f180c782 100644 --- a/src/SRFile.ts +++ b/src/SRFile.ts @@ -1,16 +1,9 @@ -import { - MetadataCache, - TFile, - Vault, - getAllTags as ObsidianGetAllTags, - HeadingCache, -} from "obsidian"; -import { getAllTagsFromText } from "./util/utils"; +import { MetadataCache, TFile, Vault, HeadingCache, TagCache, FrontMatterCache } from "obsidian"; export interface ISRFile { get path(): string; get basename(): string; - getAllTags(): string[]; + getAllTagsFromText(): TagCache[]; getQuestionContext(cardLine: number): string[]; read(): Promise; write(content: string): Promise; @@ -35,9 +28,44 @@ export class SrTFile implements ISRFile { return this.file.basename; } - getAllTags(): string[] { + getAllTagsFromText(): TagCache[] { + const result: TagCache[] = [] as TagCache[]; const fileCachedData = this.metadataCache.getFileCache(this.file) || {}; - return ObsidianGetAllTags(fileCachedData) || []; + if (fileCachedData.tags?.length > 0) { + result.push(...fileCachedData.tags); + } + + // RZ: 2024-01-28 fileCachedData.tags doesn't include the tags within the frontmatter, need to access those separately + // This is different to the Obsidian function getAllTags() which does return all tags including those within the + // frontmatter. + result.push(...this.getFrontmatterTags(fileCachedData.frontmatter)); + + return result; + } + + private getFrontmatterTags(frontmatter: FrontMatterCache): TagCache[] { + const result: TagCache[] = [] as TagCache[]; + const frontmatterTags: string = frontmatter != null ? frontmatter["tags"] + "" : null; + if (frontmatterTags) { + // The frontmatter doesn't include the line number for the specific tag, defining as line 1 is good enough. + // (determineQuestionTopicPathList() only needs to know that these frontmatter tags come before all others + // in the file) + const line: number = 1; + + // Frontmatter tags are comma separated and don't include the "#", so we need to add that in + const tagStrList: string[] = frontmatterTags.split(","); + for (const str of tagStrList) { + const tag: TagCache = { + tag: "#" + str, + position: { + start: { line: line, col: null, offset: null }, + end: { line: line, col: null, offset: null }, + }, + }; + result.push(tag); + } + } + return result; } getQuestionContext(cardLine: number): string[] { @@ -72,38 +100,3 @@ export class SrTFile implements ISRFile { await this.vault.modify(this.file, content); } } - -export class UnitTestSRFile implements ISRFile { - content: string; - _path: string; - - constructor(content: string, path: string = null) { - this.content = content; - this._path = path; - } - - get path(): string { - return this._path; - } - - get basename(): string { - return ""; - } - - getAllTags(): string[] { - return getAllTagsFromText(this.content); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getQuestionContext(cardLine: number): string[] { - return []; - } - - async read(): Promise { - return this.content; - } - - async write(content: string): Promise { - this.content = content; - } -} diff --git a/src/TopicPath.ts b/src/TopicPath.ts index 6b34bc20..575edfa8 100644 --- a/src/TopicPath.ts +++ b/src/TopicPath.ts @@ -38,34 +38,6 @@ export class TopicPath { return result; } - static getTopicPathOfFile(noteFile: ISRFile, settings: SRSettings): TopicPath { - let deckPath: string[] = []; - let result: TopicPath = TopicPath.emptyPath; - - if (settings.convertFoldersToDecks) { - deckPath = noteFile.path.split("/"); - deckPath.pop(); // remove filename - if (deckPath.length != 0) { - result = new TopicPath(deckPath); - } - } else { - const tagList: TopicPath[] = this.getTopicPathsFromTagList(noteFile.getAllTags()); - - outer: for (const tagToReview of this.getTopicPathsFromTagList( - settings.flashcardTags, - )) { - for (const tag of tagList) { - if (tagToReview.isSameOrAncestorOf(tag)) { - result = tag; - break outer; - } - } - } - } - - return result; - } - isSameOrAncestorOf(topicPath: TopicPath): boolean { if (this.isEmptyPath) return topicPath.isEmptyPath; if (this.path.length > topicPath.path.length) return false; @@ -80,14 +52,6 @@ export class TopicPath { return path?.length > 0 ? TopicPath.getTopicPathFromTag(path) : null; } - static getTopicPathsFromTagList(tagList: string[]): TopicPath[] { - const result: TopicPath[] = []; - for (const tag of tagList) { - if (this.isValidTag(tag)) result.push(TopicPath.getTopicPathFromTag(tag)); - } - return result; - } - static isValidTag(tag: string): boolean { if (tag == null || tag.length == 0) return false; if (tag[0] != "#") return false; @@ -107,6 +71,91 @@ export class TopicPath { .filter((str) => str); return new TopicPath(path); } + + static getFolderPathFromFilename(noteFile: ISRFile, settings: SRSettings): TopicPath { + let result: TopicPath = TopicPath.emptyPath; + + if (settings.convertFoldersToDecks) { + const deckPath: string[] = noteFile.path.split("/"); + deckPath.pop(); // remove filename + if (deckPath.length != 0) { + result = new TopicPath(deckPath); + } + } + + return result; + } +} + +export class TopicPathList { + list: TopicPath[]; + lineNum: number; + + constructor(list: TopicPath[], lineNum: number = null) { + if (list == null) throw "TopicPathList null"; + this.list = list; + this.lineNum = lineNum; + } + + get length(): number { + return this.list.length; + } + + isAnyElementSameOrAncestorOf(topicPath: TopicPath): boolean { + return this.list.some((item) => item.isSameOrAncestorOf(topicPath)); + } + + formatPsv() { + return this.format("|"); + } + + format(sep: string) { + return this.list.map((topicPath) => topicPath.formatAsTag()).join(sep); + } + + static empty(): TopicPathList { + return new TopicPathList([]); + } + + static fromPsv(str: string, lineNum: number): TopicPathList { + const result: TopicPathList = TopicPathList.convertTagListToTopicPathList(str.split("|")); + result.lineNum = lineNum; + return result; + } + + // + // tagList is a list of tags such as: + // ["#flashcards/computing", "#boring-stuff", "#news-worthy"] + // validTopicPathList is a list of valid tags, such as those from settings.flashcardTags,E.g. + // ["#flashcards"] + // + // This returns a filtered version of tagList, containing only topic paths that are considered valid. + // Validity is defined as "isAnyElementSameOrAncestorOf", and "#flashcards" is considered the ancestor of + // "#flashcards/computing". + // + // Therefore this would return: + // "#flashcards/computing" (but not "#boring-stuff" or "#news-worthy") + // + static filterValidTopicPathsFromTagList( + list: TopicPathList, + validTopicPathList: TopicPathList, + lineNum: number = null, + ): TopicPathList { + const result: TopicPath[] = []; + for (const tag of list.list) { + if (validTopicPathList.isAnyElementSameOrAncestorOf(tag)) result.push(tag); + } + + return new TopicPathList(result, lineNum); + } + + static convertTagListToTopicPathList(tagList: string[]): TopicPathList { + const result: TopicPath[] = []; + for (const tag of tagList) { + if (TopicPath.isValidTag(tag)) result.push(TopicPath.getTopicPathFromTag(tag)); + } + return new TopicPathList(result); + } } export class TopicPathWithWs { diff --git a/src/gui/flashcard-modal.tsx b/src/gui/flashcard-modal.tsx index a574de4d..f73cbca9 100644 --- a/src/gui/flashcard-modal.tsx +++ b/src/gui/flashcard-modal.tsx @@ -449,7 +449,9 @@ export class FlashcardModal extends Modal { this.responseDiv.style.display = "none"; this.resetButton.disabled = true; - this.titleEl.setText(`${deck.deckName}: ${deck.getCardCount(CardListType.All, true)}`); + this.titleEl.setText( + `${deck.deckName}: ${deck.getDistinctCardCount(CardListType.All, true)}`, + ); this.answerBtn.style.display = "initial"; this.flashcardView.empty(); diff --git a/src/gui/stats-modal.tsx b/src/gui/stats-modal.tsx index e04cb870..06072586 100644 --- a/src/gui/stats-modal.tsx +++ b/src/gui/stats-modal.tsx @@ -170,7 +170,10 @@ export class StatsModal extends Modal { ); // Add card types - const totalCardsCount: number = this.plugin.deckTree.getCardCount(CardListType.All, true); + const totalCardsCount: number = this.plugin.deckTree.getDistinctCardCount( + CardListType.All, + true, + ); createStatsChart( "pie", "cardTypesChart", diff --git a/src/main.ts b/src/main.ts index 1a46390e..c63a8e08 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,6 @@ import { DeckTreeIterator, IDeckTreeIterator, IIteratorOrder, - IteratorDeckSource, DeckOrder, } from "./DeckTreeIterator"; import { CardScheduleCalculator } from "./CardSchedule"; @@ -286,8 +285,7 @@ export default class SRPlugin extends Plugin { noteFile: TFile, reviewMode: FlashcardReviewMode, ): Promise { - const topicPath: TopicPath = this.findTopicPath(this.createSrTFile(noteFile)); - const note: Note = await this.loadNote(noteFile, topicPath); + const note: Note = await this.loadNote(noteFile); const deckTree = new Deck("root", null); note.appendCardsToDeck(deckTree); @@ -304,7 +302,7 @@ export default class SRPlugin extends Plugin { remainingDeckTree: Deck, reviewMode: FlashcardReviewMode, ): void { - const deckIterator = SRPlugin.createDeckTreeIterator(this.data.settings); + const deckIterator = SRPlugin.createDeckTreeIterator(this.data.settings, remainingDeckTree); const cardScheduleCalculator = new CardScheduleCalculator( this.data.settings, this.easeByPath, @@ -321,18 +319,17 @@ export default class SRPlugin extends Plugin { new FlashcardModal(this.app, this, this.data.settings, reviewSequencer, reviewMode).open(); } - private static createDeckTreeIterator(settings: SRSettings): IDeckTreeIterator { + private static createDeckTreeIterator(settings: SRSettings, baseDeck: Deck): IDeckTreeIterator { let cardOrder: CardOrder = CardOrder[settings.flashcardCardOrder as keyof typeof CardOrder]; if (cardOrder === undefined) cardOrder = CardOrder.DueFirstSequential; let deckOrder: DeckOrder = DeckOrder[settings.flashcardDeckOrder as keyof typeof DeckOrder]; if (deckOrder === undefined) deckOrder = DeckOrder.PrevDeckComplete_Sequential; - console.log(`createDeckTreeIterator: CardOrder: ${cardOrder}, DeckOrder: ${deckOrder}`); const iteratorOrder: IIteratorOrder = { deckOrder, cardOrder, }; - return new DeckTreeIterator(iteratorOrder, IteratorDeckSource.UpdatedByIterator); + return new DeckTreeIterator(iteratorOrder, baseDeck); } async sync(): Promise { @@ -392,9 +389,8 @@ export default class SRPlugin extends Plugin { } } - const topicPath: TopicPath = this.findTopicPath(this.createSrTFile(noteFile)); - if (topicPath.hasPath) { - const note: Note = await this.loadNote(noteFile, topicPath); + const note: Note = await this.loadNote(noteFile); + if (note.questionList.length > 0) { const flashcardsInNoteAvgEase: number = NoteEaseCalculator.Calculate( note, this.data.settings, @@ -405,7 +401,6 @@ export default class SRPlugin extends Plugin { this.easeByPath.setEaseForPath(note.filePath, flashcardsInNoteAvgEase); } } - const fileCachedData = this.app.metadataCache.getFileCache(noteFile) || {}; const frontmatter: FrontMatterCache | Record = @@ -525,17 +520,28 @@ export default class SRPlugin extends Plugin { this.statusBar.setText( t("STATUS_BAR", { dueNotesCount: this.dueNotesCount, - dueFlashcardsCount: this.remainingDeckTree.getCardCount(CardListType.All, true), + dueFlashcardsCount: this.remainingDeckTree.getDistinctCardCount( + CardListType.All, + true, + ), }), ); if (this.data.settings.enableNoteReviewPaneOnStartup) this.reviewQueueView.redraw(); } - async loadNote(noteFile: TFile, topicPath: TopicPath): Promise { + async loadNote(noteFile: TFile): Promise { const loader: NoteFileLoader = new NoteFileLoader(this.data.settings); - const note: Note = await loader.load(this.createSrTFile(noteFile), topicPath); - if (note.hasChanged) note.writeNoteFile(this.data.settings); + const srFile: ISRFile = this.createSrTFile(noteFile); + const folderTopicPath: TopicPath = TopicPath.getFolderPathFromFilename( + srFile, + this.data.settings, + ); + + const note: Note = await loader.load(this.createSrTFile(noteFile), folderTopicPath); + if (note.hasChanged) { + note.writeNoteFile(this.data.settings); + } return note; } @@ -665,8 +671,7 @@ export default class SRPlugin extends Plugin { } if (this.data.settings.burySiblingCards) { - const topicPath: TopicPath = this.findTopicPath(this.createSrTFile(note)); - const noteX: Note = await this.loadNote(note, topicPath); + const noteX: Note = await this.loadNote(note); for (const question of noteX.questionList) { this.data.buryList.push(question.questionText.textHash); } @@ -758,10 +763,6 @@ export default class SRPlugin extends Plugin { return new SrTFile(this.app.vault, this.app.metadataCache, note); } - findTopicPath(note: ISRFile): TopicPath { - return TopicPath.getTopicPathOfFile(note, this.data.settings); - } - async loadPluginData(): Promise { const loadedData: PluginData = await this.loadData(); if (loadedData?.settings) upgradeSettings(loadedData.settings); diff --git a/src/parser.ts b/src/parser.ts index b1701089..2e8e8c1f 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,5 +1,25 @@ import { CardType } from "./Question"; +export class ParsedQuestionInfo { + cardType: CardType; + text: string; + + // Line numbers start at 0 + firstLineNum: number; + lastLineNum: number; + + constructor(cardType: CardType, text: string, firstLineNum: number, lastLineNum: number) { + this.cardType = cardType; + this.text = text; + this.firstLineNum = firstLineNum; + this.lastLineNum = lastLineNum; + } + + isQuestionLineNum(lineNum: number): boolean { + return lineNum >= this.firstLineNum && lineNum <= this.lastLineNum; + } +} + /** * Returns flashcards found in `text` * @@ -10,7 +30,7 @@ import { CardType } from "./Question"; * @param multilineReversedCardSeparator - Separator for multiline basic card * @returns An array of [CardType, card text, line number] tuples */ -export function parse( +export function parseEx( text: string, singlelineCardSeparator: string, singlelineReversedCardSeparator: string, @@ -19,18 +39,20 @@ export function parse( convertHighlightsToClozes: boolean, convertBoldTextToClozes: boolean, convertCurlyBracketsToClozes: boolean, -): [CardType, string, number][] { +): ParsedQuestionInfo[] { let cardText = ""; - const cards: [CardType, string, number][] = []; + const cards: ParsedQuestionInfo[] = []; let cardType: CardType | null = null; - let lineNo = 0; + let firstLineNo = 0; + let lastLineNo = 0; const lines: string[] = text.replaceAll("\r\n", "\n").split("\n"); for (let i = 0; i < lines.length; i++) { const currentLine = lines[i]; if (currentLine.length === 0) { if (cardType) { - cards.push([cardType, cardText, lineNo]); + lastLineNo = i - 1; + cards.push(new ParsedQuestionInfo(cardType, cardText, firstLineNo, lastLineNo)); cardType = null; } @@ -44,6 +66,9 @@ export function parse( if (cardText.length > 0) { cardText += "\n"; + } else if (cardText.length === 0) { + // This could be the first line of a multi line question + firstLineNo = i; } cardText += currentLine.trimEnd(); @@ -55,12 +80,13 @@ export function parse( ? CardType.SingleLineReversed : CardType.SingleLineBasic; cardText = lines[i]; - lineNo = i; + firstLineNo = i; if (i + 1 < lines.length && lines[i + 1].startsWith(" Q3::A3 @@ -97,16 +91,16 @@ Q5::A5 Q6::A6`; let deck: Deck = await SampleItemDecks.createDeckFromText( text, - new TopicPath(["Root"]), + TopicPath.emptyPath, ); iterator = new DeckTreeIterator( { cardOrder: CardOrder.DueFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); // Scheduled cards first nextCardThenCheck("Q2"); @@ -140,16 +134,16 @@ Q6::A6`; `; let deck: Deck = await SampleItemDecks.createDeckFromText( text, - new TopicPath(["Root"]), + TopicPath.emptyPath, ); iterator = new DeckTreeIterator( { cardOrder: CardOrder.DueFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); // Due root deck's cards first nextCardThenCheck("Q2"); @@ -177,25 +171,25 @@ Q6::A6`; describe("New cards before due cards", () => { test("Single topic, new cards only", async () => { - let text: string = ` + let text: string = `#flashcards Q1::A1 Q2::A2 Q3::A3`; let deck: Deck = await SampleItemDecks.createDeckFromText( text, - new TopicPath(["Root"]), + TopicPath.emptyPath, ); let iterator: DeckTreeIterator = new DeckTreeIterator( { cardOrder: CardOrder.NewFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); expect(iterator.nextCard()).toEqual(true); - expect(iterator.currentDeck.deckName).toEqual("Root"); + expect(iterator.currentDeck.deckName).toEqual("flashcards"); expect(iterator.currentCard.front).toEqual("Q1"); expect(iterator.nextCard()).toEqual(true); @@ -209,7 +203,7 @@ Q3::A3`; describe("Single topic, mixture of new and scheduled cards", () => { test("Get the new cards first", async () => { - let text: string = ` + let text: string = `#flashcards Q1::A1 Q2::A2 Q3::A3 @@ -218,16 +212,16 @@ Q5::A5 Q6::A6`; let deck: Deck = await SampleItemDecks.createDeckFromText( text, - new TopicPath(["Root"]), + TopicPath.emptyPath, ); iterator = new DeckTreeIterator( { cardOrder: CardOrder.NewFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); // New cards first nextCardThenCheck("Q1"); @@ -244,7 +238,7 @@ Q6::A6`; }); test("Get the scheduled cards first", async () => { - let text: string = ` + let text: string = `#flashcards Q1::A1 Q2::A2 Q3::A3 @@ -253,16 +247,16 @@ Q5::A5 Q6::A6`; let deck: Deck = await SampleItemDecks.createDeckFromText( text, - new TopicPath(["Root"]), + TopicPath.emptyPath, ); iterator = new DeckTreeIterator( { cardOrder: CardOrder.DueFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); // Scheduled cards first nextCardThenCheck("Q2"); @@ -296,16 +290,16 @@ Q6::A6`; `; let deck: Deck = await SampleItemDecks.createDeckFromText( text, - new TopicPath(["Root"]), + TopicPath.emptyPath, ); iterator = new DeckTreeIterator( { cardOrder: CardOrder.NewFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); // New root deck's cards first nextCardThenCheck("Q1"); @@ -333,7 +327,7 @@ Q6::A6`; describe("DeckOrder.PrevDeckComplete_Sequential; Random card ordering", () => { describe("Due cards before new cards", () => { test("All new cards", async () => { - let text: string = ` + let text: string = `#flashcards Q0::A0 Q1::A1 Q2::A2 @@ -343,16 +337,16 @@ Q5::A5 Q6::A6`; let deck: Deck = await SampleItemDecks.createDeckFromText( text, - new TopicPath(["Root"]), + TopicPath.emptyPath, ); iterator = new DeckTreeIterator( { cardOrder: CardOrder.DueFirstRandom, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); // [0, 1, 2, 3, 4, 5, 6] setupNextRandomNumber({ lower: 0, upper: 6, next: 5 }); @@ -381,7 +375,7 @@ Q6::A6`; }); test("Mixture new/scheduled", async () => { - let text: string = ` + let text: string = `#flashcards QN0::A QS0::A QN1::A @@ -392,16 +386,16 @@ QN3::A QS3::Q `; let deck: Deck = await SampleItemDecks.createDeckFromText( text, - new TopicPath(["Root"]), + TopicPath.emptyPath, ); iterator = new DeckTreeIterator( { cardOrder: CardOrder.DueFirstRandom, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); // Scheduled cards first // [QN0, QN1, QN2, QN3], [QS0, QS1, QS2, QS3] @@ -458,18 +452,15 @@ QS3::Q `; #flashcards/science/chemistry Q8::A8 `; - let deck: Deck = await SampleItemDecks.createDeckFromText( - text, - new TopicPath(["Root"]), - ); + let deck: Deck = await SampleItemDecks.createDeckFromText(text, TopicPath.emptyPath); iterator = new DeckTreeIterator( { cardOrder: CardOrder.NewFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Random, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); // New root deck's cards first Q1/Q3, then due cards - Q2 setupNextRandomNumber({ lower: 0, upper: 3, next: 0 }); @@ -516,18 +507,15 @@ QS3::Q `; #flashcards/science/chemistry Q8::A8 `; - let deck: Deck = await SampleItemDecks.createDeckFromText( - text, - new TopicPath(["Root"]), - ); + let deck: Deck = await SampleItemDecks.createDeckFromText(text, TopicPath.emptyPath); iterator = new DeckTreeIterator( { cardOrder: CardOrder.EveryCardRandomDeckAndCard, deckOrder: null, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); // 8 cards to choose from (hence we expect the random number provider to be asked // for a random number 0... 7): @@ -570,38 +558,129 @@ QS3::Q `; }); }); +describe("nextCard - Some cards present in multiple decks", () => { + describe("DeckOrder.PrevDeckComplete_Sequential; Sequential card ordering", () => { + test("Iterating over complete deck tree", async () => { + let text: string = `#flashcards +Q1::A1 + +#flashcards/folder1 +Q21::A21 + +#flashcards/folder2 +Q31::A31 + +#flashcards/folder1 #flashcards/folder2 +Q11::A11 +Q12::A12 +`; + const [deck, iterator] = await SampleItemDecks.createDeckAndIteratorFromText( + text, + TopicPath.emptyPath, + CardOrder.DueFirstSequential, + DeckOrder.PrevDeckComplete_Sequential, + ); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); + + // Start off with cards in the top most deck, i.e. #flashcards + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentDeck.deckName).toEqual("flashcards"); + expect(iterator.currentCard.front).toEqual("Q1"); + + // Now those in #flashcards/folder1 + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q21"); // Specific to #flashcards/folder1 + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q11"); // Common to #flashcards/folder1 & folder2 + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q12"); // Common to #flashcards/folder1 & folder2 + + // Now those in #flashcards/folder2 + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q31"); + + // Ones common to both folder1 and folder2 are not returned for folder2 + // i.e. we don't see Q11 or Q12 again + expect(iterator.nextCard()).toEqual(false); + }); + + test("Iterating over portion of deck tree still deletes hard-linked cards in non-iterated portion of the deck", async () => { + let text: string = `#flashcards +Q1::A1 + +#flashcards/folder1 +Q21::A21 + +#flashcards/folder2 +Q31::A31 + +#flashcards/folder1 #flashcards/folder2 +Q11::A11 +Q12::A12 +`; + const [deck, iterator] = await SampleItemDecks.createDeckAndIteratorFromText( + text, + TopicPath.emptyPath, + CardOrder.DueFirstSequential, + DeckOrder.PrevDeckComplete_Sequential, + ); + + // Before iterating folder2, there are (1 + 2) cards in folder1 + let subdeck: Deck = deck.getDeckByTopicTag("#flashcards/folder1"); + expect(subdeck.getCardCount(CardListType.All, false)).toEqual(3); + + // Iterate cards in #flashcards/folder2 + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards/folder2")); + + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q31"); + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q11"); + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q12"); + expect(iterator.nextCard()).toEqual(false); + + // After iterating folder2, there are (1 + 0) cards in folder1 + subdeck = deck.getDeckByTopicTag("#flashcards/folder1"); + expect(subdeck.getCardCount(CardListType.All, false)).toEqual(1); + }); + }); +}); + describe("hasCurrentCard", () => { test("false immediately after setDeck", async () => { - let text: string = ` + let text: string = `#flashcards Q1::A1 Q2::A2 Q3::A3`; - let deck: Deck = await SampleItemDecks.createDeckFromText(text, new TopicPath(["Root"])); + let deck: Deck = await SampleItemDecks.createDeckFromText(text, TopicPath.emptyPath); let iterator: DeckTreeIterator = new DeckTreeIterator( { cardOrder: CardOrder.NewFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); + expect(iterator.hasCurrentCard).toEqual(false); }); test("true immediately after nextCard", async () => { - let text: string = ` + let text: string = `#flashcards Q1::A1 Q2::A2 Q3::A3`; - let deck: Deck = await SampleItemDecks.createDeckFromText(text, new TopicPath(["Root"])); + let deck: Deck = await SampleItemDecks.createDeckFromText(text, TopicPath.emptyPath); let iterator: DeckTreeIterator = new DeckTreeIterator( { cardOrder: CardOrder.NewFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); + expect(iterator.nextCard()).toEqual(true); expect(iterator.hasCurrentCard).toEqual(true); }); @@ -609,19 +688,19 @@ describe("hasCurrentCard", () => { describe("deleteCurrentCard", () => { test("Delete after all cards iterated - exception throw", async () => { - let text: string = ` + let text: string = `#flashcards Q1::A1 Q2::A2 Q3::A3`; - let deck: Deck = await SampleItemDecks.createDeckFromText(text, new TopicPath(["Root"])); + let deck: Deck = await SampleItemDecks.createDeckFromText(text, TopicPath.emptyPath); iterator = new DeckTreeIterator( { cardOrder: CardOrder.NewFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); expect(iterator.nextCard()).toEqual(true); expect(iterator.nextCard()).toEqual(true); @@ -629,32 +708,33 @@ Q3::A3`; expect(iterator.nextCard()).toEqual(false); const t = () => { - iterator.deleteCurrentCard(); + iterator.deleteCurrentCardFromAllDecks(); }; expect(t).toThrow(); }); test("Delete card, with single card remaining after it", async () => { - let text: string = ` + let text: string = `#flashcards Q1::A1 Q2::A2 Q3::A3`; - let deck: Deck = await SampleItemDecks.createDeckFromText(text, new TopicPath(["Root"])); - expect(deck.newFlashcards.length).toEqual(3); + let deck: Deck = await SampleItemDecks.createDeckFromText(text, TopicPath.emptyPath); + const flashcardDeck: Deck = deck.getDeckByTopicTag("#flashcards"); + expect(flashcardDeck.newFlashcards.length).toEqual(3); iterator = new DeckTreeIterator( { cardOrder: CardOrder.NewFirstSequential, deckOrder: DeckOrder.PrevDeckComplete_Sequential, }, - IteratorDeckSource.UpdatedByIterator, + deck, ); - iterator.setDeck(deck); + iterator.setIteratorTopicPath(TopicPath.getTopicPathFromTag("#flashcards")); nextCardThenCheck("Q1"); nextCardThenCheck("Q2"); - expect(iterator.deleteCurrentCard()).toEqual(true); + expect(iterator.deleteCurrentCardFromAllDecks()).toEqual(true); expect(iterator.currentCard.front).toEqual("Q3"); - expect(iterator.deleteCurrentCard()).toEqual(false); + expect(iterator.deleteCurrentCardFromAllDecks()).toEqual(false); }); }); diff --git a/tests/unit/FlashcardReviewSequencer.test.ts b/tests/unit/FlashcardReviewSequencer.test.ts index 9207c8b1..7d67a93f 100644 --- a/tests/unit/FlashcardReviewSequencer.test.ts +++ b/tests/unit/FlashcardReviewSequencer.test.ts @@ -5,9 +5,9 @@ import { DeckTreeIterator, IDeckTreeIterator, IIteratorOrder, - IteratorDeckSource, } from "src/DeckTreeIterator"; import { + DeckStats, FlashcardReviewMode, FlashcardReviewSequencer, IFlashcardReviewSequencer, @@ -16,7 +16,6 @@ import { TopicPath } from "src/TopicPath"; import { CardListType, Deck, DeckTreeFilter } from "src/Deck"; import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; import { SampleItemDecks } from "./SampleItems"; -import { UnitTestSRFile } from "src/SRFile"; import { ReviewResponse } from "src/scheduling"; import { setupStaticDateProvider, @@ -26,6 +25,7 @@ import { import moment from "moment"; import { INoteEaseList, NoteEaseList } from "src/NoteEaseList"; import { QuestionPostponementList, IQuestionPostponementList } from "src/QuestionPostponementList"; +import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; let order_DueFirst_Sequential: IIteratorOrder = { cardOrder: CardOrder.DueFirstSequential, @@ -44,7 +44,7 @@ class TestContext { cardSequencer: IDeckTreeIterator; noteEaseList: INoteEaseList; cardScheduleCalculator: CardScheduleCalculator; - reviewSequencer: IFlashcardReviewSequencer; + reviewSequencer: FlashcardReviewSequencer; questionPostponementList: QuestionPostponementList; file: UnitTestSRFile; originalText: string; @@ -57,11 +57,8 @@ class TestContext { async resetContext(text: string, daysAfterOrigin: number): Promise { this.originalText = text; this.file.content = text; - let cardSequencer: IDeckTreeIterator = new DeckTreeIterator( - this.iteratorOrder, - IteratorDeckSource.UpdatedByIterator, - ); - let reviewSequencer: IFlashcardReviewSequencer = new FlashcardReviewSequencer( + let cardSequencer: IDeckTreeIterator = new DeckTreeIterator(this.iteratorOrder, null); + let reviewSequencer: FlashcardReviewSequencer = new FlashcardReviewSequencer( this.reviewMode, cardSequencer, this.settings, @@ -80,7 +77,7 @@ class TestContext { } async setSequencerDeckTreeFromOriginalText(): Promise { - const deckTree: Deck = await SampleItemDecks.createDeckFromFile( + let deckTree: Deck = await SampleItemDecks.createDeckFromFile( this.file, new TopicPath(["Root"]), ); @@ -93,6 +90,10 @@ class TestContext { return deckTree; } + getDeckStats(topicTag: string): DeckStats { + return this.reviewSequencer.getDeckStats(TopicPath.getTopicPathFromTag(topicTag)); + } + static Create( iteratorOrder: IIteratorOrder, reviewMode: FlashcardReviewMode, @@ -100,10 +101,7 @@ class TestContext { text: string, fakeFilePath?: string, ): TestContext { - let cardSequencer: IDeckTreeIterator = new DeckTreeIterator( - iteratorOrder, - IteratorDeckSource.UpdatedByIterator, - ); + let cardSequencer: IDeckTreeIterator = new DeckTreeIterator(iteratorOrder, null); let noteEaseList = new NoteEaseList(settings); let cardScheduleCalculator: CardScheduleCalculator = new CardScheduleCalculator( settings, @@ -114,7 +112,7 @@ class TestContext { settings, [], ); - let reviewSequencer: IFlashcardReviewSequencer = new FlashcardReviewSequencer( + let reviewSequencer: FlashcardReviewSequencer = new FlashcardReviewSequencer( reviewMode, cardSequencer, settings, @@ -316,6 +314,7 @@ describe("setDeckTree", () => { DEFAULT_SETTINGS, "", ); + c.setSequencerDeckTreeFromOriginalText(); c.reviewSequencer.setDeckTree(Deck.emptyDeck, Deck.emptyDeck); expect(c.reviewSequencer.currentDeck).toEqual(null); expect(c.reviewSequencer.currentCard).toEqual(null); @@ -323,7 +322,7 @@ describe("setDeckTree", () => { // After setDeckTree, the first card in the deck is the current card test("Single level deck with some new cards", async () => { - let text: string = ` + let text: string = `#flashcards Q1::A1 Q2::A2 Q3::A3`; @@ -334,7 +333,8 @@ Q3::A3`; text, ); let deck: Deck = await c.setSequencerDeckTreeFromOriginalText(); - expect(deck.newFlashcards.length).toEqual(3); + const flashcardDeck: Deck = deck.getDeckByTopicTag("#flashcards"); + expect(flashcardDeck.newFlashcards.length).toEqual(3); expect(c.reviewSequencer.currentDeck.newFlashcards.length).toEqual(3); let expected = { @@ -505,7 +505,8 @@ describe("processReview", () => { let settings: SRSettings = { ...DEFAULT_SETTINGS }; settings.burySiblingCards = false; - let text: string = ` + let text: string = `#flashcards + #flashcards This single ==question== turns into ==3 separate== ==cards== Q1::A1 @@ -547,6 +548,7 @@ Q1::A1 let text: string = ` #flashcards ${clozeQuestion1} +#flashcards Q1::A1 `; @@ -580,6 +582,7 @@ Q1::A1 let text: string = ` #flashcards ${clozeQuestion1} +#flashcards Q1::A1 `; @@ -698,7 +701,7 @@ $$\\huge F_g=\\frac {G m_1 m_2}{d^2}$$`; let indent: string = " "; // Note that "- bar?::baz" is intentionally indented - let text: string = ` + let text: string = `#flashcards - foo ${indent}- bar?::baz `; @@ -1020,6 +1023,85 @@ ${updatedQuestionText}`; }); }); +describe("getDeckStats", () => { + describe("Single level deck with some new and due cards", () => { + test("Initial stats", async () => { + let text: string = `#flashcards +Q1::A1 +Q2::A2 +Q3::A3 +Q4::A4 +`; + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Review, + DEFAULT_SETTINGS, + text, + ); + let deck: Deck = await c.setSequencerDeckTreeFromOriginalText(); + expect(c.getDeckStats("#flashcards")).toEqual(new DeckStats(1, 3, 4)); + }); + + test("Reduction in due count after skipping card", async () => { + let text: string = `#flashcards +Q1::A1 +Q2::A2 +Q3::A3 +Q4::A4 +`; + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Review, + DEFAULT_SETTINGS, + text, + ); + let deck: Deck = await c.setSequencerDeckTreeFromOriginalText(); + + expect(c.reviewSequencer.currentCard.front).toEqual("Q4"); // This is the first card as we are using order_DueFirst_Sequential + expect(c.getDeckStats("#flashcards")).toEqual(new DeckStats(1, 3, 4)); + c.reviewSequencer.skipCurrentCard(); + // One less due card + expect(c.getDeckStats("#flashcards")).toEqual(new DeckStats(0, 3, 4)); + }); + + test("Change in stats after reviewing each card", async () => { + let text: string = `#flashcards +Q1::A1 +Q2::A2 +Q3::A3 +Q4::A4 +`; + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Review, + DEFAULT_SETTINGS, + text, + ); + let deck: Deck = await c.setSequencerDeckTreeFromOriginalText(); + + await checkStats(c, "#flashcards", [ + [new DeckStats(1, 3, 4), "Q4", ReviewResponse.Easy], // This is the first card as we are using order_DueFirst_Sequential + [new DeckStats(0, 3, 4), "Q1", ReviewResponse.Easy], // Iterated through all the due cards, now the new ones + [new DeckStats(0, 2, 4), "Q2", ReviewResponse.Easy], + ]); + }); + }); +}); + +async function checkStats( + c: TestContext, + topicPath: string, + expectedStats: [DeckStats, string, ReviewResponse][], +): Promise { + for (const item of expectedStats) { + const [expectedDeckStats, expectedCardFront, reviewResponse] = item; + expect(c.getDeckStats(topicPath)).toEqual(expectedDeckStats); + if (expectedCardFront) + expect(c.reviewSequencer.currentCard.front).toEqual(expectedCardFront); + if (reviewResponse != null) await c.reviewSequencer.processReview(reviewResponse); + } +} + describe("Sequences", () => { test("Update question text, followed by review response", async () => { let text1: string = ` diff --git a/tests/unit/Note.test.ts b/tests/unit/Note.test.ts index 96b59024..c38af8b0 100644 --- a/tests/unit/Note.test.ts +++ b/tests/unit/Note.test.ts @@ -1,11 +1,11 @@ import { NoteParser } from "src/NoteParser"; -import { UnitTestSRFile } from "src/SRFile"; import { TopicPath } from "src/TopicPath"; import { Deck } from "src/Deck"; import { Note } from "src/Note"; import { Question } from "src/Question"; import { DEFAULT_SETTINGS } from "src/settings"; import { NoteFileLoader } from "src/NoteFileLoader"; +import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; let parser: NoteParser = new NoteParser(DEFAULT_SETTINGS); var noteFileLoader: NoteFileLoader = new NoteFileLoader(DEFAULT_SETTINGS); diff --git a/tests/unit/NoteFileLoader.test.ts b/tests/unit/NoteFileLoader.test.ts index ab07ae5f..ce2c2fbc 100644 --- a/tests/unit/NoteFileLoader.test.ts +++ b/tests/unit/NoteFileLoader.test.ts @@ -1,8 +1,8 @@ import { Note } from "src/Note"; import { NoteFileLoader } from "src/NoteFileLoader"; -import { UnitTestSRFile } from "src/SRFile"; import { TopicPath } from "src/TopicPath"; import { DEFAULT_SETTINGS } from "src/settings"; +import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; var noteFileLoader: NoteFileLoader = new NoteFileLoader(DEFAULT_SETTINGS); diff --git a/tests/unit/NoteParser.test.ts b/tests/unit/NoteParser.test.ts index fb57ed00..a7768492 100644 --- a/tests/unit/NoteParser.test.ts +++ b/tests/unit/NoteParser.test.ts @@ -1,10 +1,10 @@ import { NoteParser } from "src/NoteParser"; -import { UnitTestSRFile } from "src/SRFile"; import { TopicPath } from "src/TopicPath"; import { Note } from "src/Note"; import { Question } from "src/Question"; import { DEFAULT_SETTINGS } from "src/settings"; import { setupStaticDateProvider_20230906 } from "src/util/DateProvider"; +import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; let parser: NoteParser = new NoteParser(DEFAULT_SETTINGS); diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts index f0126016..ca00c23f 100644 --- a/tests/unit/NoteQuestionParser.test.ts +++ b/tests/unit/NoteQuestionParser.test.ts @@ -3,10 +3,11 @@ import { CardScheduleInfo } from "src/CardSchedule"; import { TICKS_PER_DAY } from "src/constants"; import { CardType, Question } from "src/Question"; import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; -import { TopicPath } from "src/TopicPath"; +import { TopicPath, TopicPathList } from "src/TopicPath"; import { createTest_NoteQuestionParser } from "./SampleItems"; -import { ISRFile, UnitTestSRFile } from "src/SRFile"; +import { ISRFile } from "src/SRFile"; import { setupStaticDateProvider_20230906 } from "src/util/DateProvider"; +import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; let parserWithDefaultSettings: NoteQuestionParser = createTest_NoteQuestionParser(DEFAULT_SETTINGS); let settings_ConvertFoldersToDecks: SRSettings = { ...DEFAULT_SETTINGS }; @@ -19,19 +20,31 @@ beforeAll(() => { setupStaticDateProvider_20230906(); }); -test("No questions in the text", async () => { - let noteText: string = "An interesting note, but no questions"; - let folderTopicPath: TopicPath = TopicPath.emptyPath; - let noteFile: ISRFile = new UnitTestSRFile(noteText); +describe("No flashcard questions", () => { + test("No questions in the text", async () => { + let noteText: string = "An interesting note, but no questions"; + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + expect( + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + ).toEqual([]); + }); + + test("A question in the text, but no flashcard tag", async () => { + let noteText: string = "A::B"; + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let noteFile: ISRFile = new UnitTestSRFile(noteText); - expect(await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath)).toEqual( - [], - ); + expect( + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + ).toEqual([]); + }); }); describe("Single question in the text (without block identifier)", () => { test("SingleLineBasic: No schedule info", async () => { - let noteText: string = ` + let noteText: string = `#flashcards A::B `; let noteFile: ISRFile = new UnitTestSRFile(noteText); @@ -44,20 +57,19 @@ A::B let expected = [ { questionType: CardType.SingleLineBasic, - topicPath: TopicPath.emptyPath, + topicPathList: TopicPathList.fromPsv("#flashcards", 0), questionText: { original: `A::B`, actualQuestion: "A::B", }, - lineNo: 1, hasEditLaterTag: false, cards: [card1], hasChanged: false, }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath), + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), ).toMatchObject(expected); }); @@ -82,7 +94,7 @@ A::B let expected = [ { questionType: CardType.SingleLineBasic, - topicPath: new TopicPath(["flashcards", "test"]), + topicPathList: TopicPathList.fromPsv("#flashcards/test", 0), questionText: { original: `A::B `, @@ -96,14 +108,14 @@ A::B }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath), + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), ).toMatchObject(expected); }); }); describe("Single question in the text (with block identifier)", () => { test("SingleLineBasic: No schedule info", async () => { - let noteText: string = ` + let noteText: string = `#flashcards A::B ^d7cee0 `; let noteFile: ISRFile = new UnitTestSRFile(noteText); @@ -115,22 +127,27 @@ A::B ^d7cee0 }; let expected = [ { - questionType: CardType.SingleLineBasic, - topicPath: TopicPath.emptyPath, + topicPathList: { + list: [TopicPath.getTopicPathFromTag("#flashcards")], + lineNum: 0, + }, + parsedQuestionInfo: { + cardType: CardType.SingleLineBasic, + firstLineNum: 1, + }, questionText: { original: `A::B ^d7cee0`, actualQuestion: "A::B", obsidianBlockId: "^d7cee0", }, - lineNo: 1, hasEditLaterTag: false, cards: [card1], hasChanged: false, }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath), + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), ).toMatchObject(expected); }); @@ -154,8 +171,14 @@ A::B ^d7cee0 }; let expected = [ { - questionType: CardType.SingleLineBasic, - topicPath: new TopicPath(["flashcards", "test"]), + topicPathList: { + list: [TopicPath.getTopicPathFromTag("#flashcards/test")], + lineNum: 0, + }, + parsedQuestionInfo: { + cardType: CardType.SingleLineBasic, + firstLineNum: 1, + }, questionText: { original: `A::B ^d7cee0 `, @@ -163,14 +186,13 @@ A::B ^d7cee0 textHash: "1c6b0b01215dc4", obsidianBlockId: "^d7cee0", }, - lineNo: 1, hasEditLaterTag: false, cards: [card1], hasChanged: false, }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath), + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), ).toMatchObject(expected); }); @@ -193,22 +215,27 @@ A::B ^d7cee0 }; let expected = [ { - questionType: CardType.SingleLineBasic, - topicPath: new TopicPath(["flashcards", "test"]), + topicPathList: { + list: [TopicPath.getTopicPathFromTag("#flashcards/test")], + lineNum: 0, // Line numbers start at zero + }, + parsedQuestionInfo: { + cardType: CardType.SingleLineBasic, + firstLineNum: 1, + }, questionText: { original: `A::B ^d7cee0`, actualQuestion: "A::B", textHash: "1c6b0b01215dc4", obsidianBlockId: "^d7cee0", }, - lineNo: 1, hasEditLaterTag: false, cards: [card1], hasChanged: false, }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath), + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), ).toMatchObject(expected); }); @@ -231,21 +258,26 @@ A::B ^d7cee0 }; let expected = [ { - questionType: CardType.SingleLineBasic, - topicPath: new TopicPath(["flashcards", "test"]), + topicPathList: { + list: [TopicPath.getTopicPathFromTag("#flashcards/test")], + lineNum: 1, + }, + parsedQuestionInfo: { + cardType: CardType.SingleLineBasic, + firstLineNum: 1, + }, questionText: { original: `#flashcards/test A::B ^d7cee0`, actualQuestion: "A::B", obsidianBlockId: "^d7cee0", }, - lineNo: 1, hasEditLaterTag: false, cards: [card1], hasChanged: false, }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath), + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), ).toMatchObject(expected); }); }); @@ -261,6 +293,7 @@ Q2::A2 let questionList: Question[] = await parser_ConvertFoldersToDecks.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(2); }); @@ -277,11 +310,38 @@ Q3::A3 let questionList: Question[] = await parser_ConvertFoldersToDecks.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(3); - expect(questionList[0].topicPath).toEqual(new TopicPath(["flashcards", "science"])); - expect(questionList[1].topicPath).toEqual(new TopicPath(["flashcards", "science"])); - expect(questionList[2].topicPath).toEqual(new TopicPath(["flashcards", "science"])); + expect(questionList[0].topicPathList.formatPsv()).toEqual("#flashcards/science"); + expect(questionList[1].topicPathList.formatPsv()).toEqual("#flashcards/science"); + expect(questionList[2].topicPathList.formatPsv()).toEqual("#flashcards/science"); + }); + + test("SingleLineBasic: Tags within frontmatter applies to all questions when not overriden", async () => { + let noteText: string = `--- +sr-due: 2024-01-17 +sr-interval: 16 +sr-ease: 278 +tags: + - flashcards/aws +--- +Q1::A1 +Q2::A2 +Q3::A3 +`; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( + noteFile, + folderTopicPath, + true, + ); + expect(questionList.length).toEqual(3); + expect(questionList[0].topicPathList.formatPsv()).toEqual("#flashcards/aws"); + expect(questionList[1].topicPathList.formatPsv()).toEqual("#flashcards/aws"); + expect(questionList[2].topicPathList.formatPsv()).toEqual("#flashcards/aws"); }); test("MultiLine: Space before multi line separator", async () => { @@ -305,6 +365,7 @@ Multiline answer2 let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, TopicPath.emptyPath, + true, ); expect(questionList.length).toEqual(3); expect(questionList[0].cards).toMatchObject([ @@ -350,10 +411,11 @@ describe("Handling tags within note", () => { let questionList: Question[] = await parser2.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(3); for (let i = 0; i < questionList.length; i++) - expect(questionList[i].topicPath).toEqual(new TopicPath(["folder", "subfolder"])); + expect(questionList[i].topicPathList.formatPsv()).toEqual("#folder/subfolder"); }); test("Topic tag within note is ignored (outside all questions)", async () => { @@ -366,9 +428,10 @@ Q1::A1 let questionList: Question[] = await parser2.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(1); - expect(questionList[0].topicPath).toEqual(new TopicPath(["folder", "subfolder"])); + expect(questionList[0].topicPathList.formatPsv()).toEqual("#folder/subfolder"); }); // Behavior here mimics SR_ORIGINAL @@ -384,9 +447,10 @@ Q1::A1 let questionList: Question[] = await parser2.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(1); - expect(questionList[0].topicPath).toEqual(new TopicPath(["folder", "subfolder"])); + expect(questionList[0].topicPathList.formatPsv()).toEqual("#folder/subfolder"); }); }); @@ -401,16 +465,17 @@ Q1::A1 `; let noteFile: ISRFile = new UnitTestSRFile(noteText); - let expectedPath: TopicPath = new TopicPath(["flashcards", "test"]); + let expectedPath: string = "#flashcards/test"; let folderTopicPath: TopicPath = TopicPath.emptyPath; let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(3); - expect(questionList[0].topicPath).toEqual(expectedPath); - expect(questionList[1].topicPath).toEqual(expectedPath); - expect(questionList[2].topicPath).toEqual(expectedPath); + expect(questionList[0].topicPathList.formatPsv()).toEqual(expectedPath); + expect(questionList[1].topicPathList.formatPsv()).toEqual(expectedPath); + expect(questionList[2].topicPathList.formatPsv()).toEqual(expectedPath); }); test("Topic tag within question overrides the note topic, for that topic only", async () => { @@ -425,11 +490,12 @@ Q1::A1 let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(3); - expect(questionList[0].topicPath).toEqual(new TopicPath(["flashcards", "test"])); - expect(questionList[1].topicPath).toEqual(new TopicPath(["flashcards", "examination"])); - expect(questionList[2].topicPath).toEqual(new TopicPath(["flashcards", "test"])); + expect(questionList[0].topicPathList.formatPsv()).toEqual("#flashcards/test"); + expect(questionList[1].topicPathList.formatPsv()).toEqual("#flashcards/examination"); + expect(questionList[2].topicPathList.formatPsv()).toEqual("#flashcards/test"); }); test("First topic tag within note (outside questions) is used as the note's topic tag, even if it appears after the first question", async () => { @@ -446,31 +512,33 @@ Q1::A1 let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(3); for (let i = 0; i < questionList.length; i++) - expect(questionList[i].topicPath).toEqual(expectedPath); + expect(questionList[i].topicPathList.formatPsv()).toEqual("#flashcards/test"); }); - test("Only first topic tag within note (outside questions) is used as the note's topic tag, subsequent ignored", async () => { + test("The last topic tag within note prior to the question is used as the note's topic tag", async () => { let noteText: string = ` Q1::A1 #flashcards/test Q2::A2 #flashcards/examination - Q3::This has the "flashcards/test" topic, not "flashcards/examination" + Q3::This has the "flashcards/examination" topic, not "flashcards/test" `; let noteFile: ISRFile = new UnitTestSRFile(noteText); - let expectedPath: TopicPath = new TopicPath(["flashcards", "test"]); let folderTopicPath: TopicPath = TopicPath.emptyPath; let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(3); - for (let i = 0; i < questionList.length; i++) - expect(questionList[i].topicPath).toEqual(expectedPath); + expect(questionList[0].topicPathList.formatPsv()).toEqual("#flashcards/test"); + expect(questionList[1].topicPathList.formatPsv()).toEqual("#flashcards/test"); + expect(questionList[2].topicPathList.formatPsv()).toEqual("#flashcards/examination"); }); }); @@ -488,9 +556,10 @@ Q1::A1 let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, folderTopicPath, + true, ); expect(questionList.length).toEqual(1); - expect(questionList[0].topicPath).toEqual(expectedPath); + expect(questionList[0].topicPathList.formatPsv()).toEqual("#flashcards/science"); expect(questionList[0].cards.length).toEqual(1); expect(questionList[0].cards[0].front).toEqual("Q5"); }); diff --git a/tests/unit/SampleItems.ts b/tests/unit/SampleItems.ts index 8c5434fe..da071b5b 100644 --- a/tests/unit/SampleItems.ts +++ b/tests/unit/SampleItems.ts @@ -6,8 +6,9 @@ import { NoteQuestionParser } from "src/NoteQuestionParser"; import { CardType, Question } from "src/Question"; import { CardFrontBack, CardFrontBackUtil } from "src/QuestionType"; import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; -import { UnitTestSRFile } from "src/SRFile"; import { TopicPath } from "src/TopicPath"; +import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; +import { CardOrder, DeckOrder, DeckTreeIterator } from "src/DeckTreeIterator"; export function createTest_NoteQuestionParser(settings: SRSettings): NoteQuestionParser { let questionParser: NoteQuestionParser = new NoteQuestionParser(settings); @@ -38,17 +39,36 @@ Q3::A3`; return deck; } - static async createDeckFromText(text: string, folderTopicPath: TopicPath): Promise { + static async createDeckFromText( + text: string, + folderTopicPath: TopicPath = TopicPath.emptyPath, + ): Promise { let file: UnitTestSRFile = new UnitTestSRFile(text); return await this.createDeckFromFile(file, folderTopicPath); } + static async createDeckAndIteratorFromText( + text: string, + folderTopicPath: TopicPath, + cardOrder: CardOrder, + deckOrder: DeckOrder, + ): Promise<[Deck, DeckTreeIterator]> { + let deck: Deck = await SampleItemDecks.createDeckFromText(text, folderTopicPath); + let iterator: DeckTreeIterator = new DeckTreeIterator( + { + cardOrder, + deckOrder, + }, + deck, + ); + return [deck, iterator]; + } + static async createDeckFromFile( file: UnitTestSRFile, - folderTopicPath: TopicPath, + folderTopicPath: TopicPath = TopicPath.emptyPath, ): Promise { let deck: Deck = new Deck("Root", null); - let topicPath: TopicPath = TopicPath.emptyPath; let noteParser: NoteParser = createTest_NoteParser(); let note: Note = await noteParser.parse(file, folderTopicPath); note.appendCardsToDeck(deck); diff --git a/tests/unit/TopicPath.test.ts b/tests/unit/TopicPath.test.ts index e6bb2ab1..af17047c 100644 --- a/tests/unit/TopicPath.test.ts +++ b/tests/unit/TopicPath.test.ts @@ -1,6 +1,7 @@ -import { ISRFile, UnitTestSRFile } from "src/SRFile"; -import { TopicPath } from "src/TopicPath"; +import { ISRFile } from "src/SRFile"; +import { TopicPath, TopicPathList } from "src/TopicPath"; import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; +import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; describe("Constructor exception handling", () => { test("Constructor rejects null path", () => { @@ -238,54 +239,3 @@ describe("isValidTag", () => { expect(TopicPath.isValidTag("#flashcards")).toEqual(true); }); }); - -describe("getTopicPathOfFile", () => { - describe("convertFoldersToDecks: false", () => { - test("Mixture of irrelevant tags and relevant ones", () => { - let content: string = ` - #ignored Q1::A1 - #ignored Q2::A2 - #also-Ignored Q3::A3 - #flashcards/science Q4::A4 - #flashcards/science/physics Q5::A5 - #flashcards/math Q6::A6`; - let file: ISRFile = new UnitTestSRFile(content); - let expected = ["flashcards", "science"]; - - expect(TopicPath.getTopicPathOfFile(file, DEFAULT_SETTINGS).path).toEqual(expected); - }); - - test("No relevant tags", () => { - let content: string = ` - #ignored Q1::A1 - #ignored Q2::A2 - #also-Ignored Q3::A3 - Q4::A4 - #ignored/science/physics Q5::A5 - Q6::A6`; - let file: ISRFile = new UnitTestSRFile(content); - - expect(TopicPath.getTopicPathOfFile(file, DEFAULT_SETTINGS).isEmptyPath).toEqual(true); - }); - }); - - describe("convertFoldersToDecks: true", () => { - let settings_ConvertFoldersToDecks: SRSettings = { ...DEFAULT_SETTINGS }; - settings_ConvertFoldersToDecks.convertFoldersToDecks = true; - test("Mixture of irrelevant tags and relevant ones", () => { - let ignoredContent: string = ` - #ignored Q1::A1 - #ignored Q2::A2 - #also-Ignored Q3::A3 - #flashcards/science Q4::A4 - #flashcards/science/physics Q5::A5 - #flashcards/math Q6::A6`; - - let fakeFilePath: string = "history/modern/Greek.md"; - let file: ISRFile = new UnitTestSRFile(ignoredContent, fakeFilePath); - let expected = ["history", "modern"]; - let actual = TopicPath.getTopicPathOfFile(file, settings_ConvertFoldersToDecks); - expect(actual.path).toEqual(expected); - }); - }); -}); diff --git a/tests/unit/deck.test.ts b/tests/unit/deck.test.ts index 1fe09605..25e305c5 100644 --- a/tests/unit/deck.test.ts +++ b/tests/unit/deck.test.ts @@ -93,6 +93,86 @@ describe("getOrCreateDeck()", () => { }); }); +describe("getDistinctCardCount()", () => { + test("Single deck", async () => { + let text: string = `#flashcards +Q1::A1 +Q2::A2 +Q3::A3 +Q4::A4 `; + const deck: Deck = await SampleItemDecks.createDeckFromText(text); + const flashcardDeck: Deck = deck.getDeckByTopicTag("#flashcards"); + expect(flashcardDeck.getDistinctCardCount(CardListType.NewCard, false)).toEqual(3); + expect(flashcardDeck.getDistinctCardCount(CardListType.DueCard, false)).toEqual(1); + expect(flashcardDeck.getDistinctCardCount(CardListType.All, false)).toEqual(4); + }); + + test("Deck hierarchy - no duplicate cards", async () => { + let text: string = `#flashcards +Q1::A1 +Q2::A2 +Q3::A3 +Q4::A4 + +#flashcards/folder1 +Q11::A11 +Q12::A12 +Q13::A13 +Q14::A14 + +#flashcards/folder1 #flashcards/folder2 +Q11::A11 +Q12::A12 +Q13::A13 +Q14::A14 Q2::A2 Q3::A3 `; let original: Deck = await SampleItemDecks.createDeckFromText( text, - new TopicPath(["Root"]), + TopicPath.emptyPath, ); let copy: Deck = original.copyWithCardFilter((card) => !card.front.includes("2")); + copy = copy.getDeck(TopicPath.getTopicPathFromTag("#flashcards")); expect(copy.newFlashcards.length).toEqual(0); expect(copy.dueFlashcards.length).toEqual(2); @@ -284,3 +366,54 @@ Q3::A3 `; }); }); }); + +describe("deleteCardFromAllDecks()", () => { + test("Single deck", async () => { + let text: string = `#flashcards +Q1::A1 +Q2::A2 +Q3::A3 +Q4::A4 `; + const deck: Deck = await SampleItemDecks.createDeckFromText(text); + const flashcardDeck: Deck = deck.getDeckByTopicTag("#flashcards"); + expect(flashcardDeck.getCardCount(CardListType.All, false)).toEqual(4); + + // We have to call on the base deck (i.e. "deck", not "flashcardDeck") + deck.deleteCardFromAllDecks(flashcardDeck.newFlashcards[1], true); + expect(flashcardDeck.getCardCount(CardListType.All, false)).toEqual(3); + expect(flashcardDeck.newFlashcards[0].front).toEqual("Q1"); + expect(flashcardDeck.newFlashcards[1].front).toEqual("Q3"); + expect(flashcardDeck.dueFlashcards[0].front).toEqual("Q4"); + }); + + test("Deck hierarchy - with duplicate cards", async () => { + let text: string = `#flashcards +Q1::A1 +Q2::A2 +Q3::A3 +Q4::A4 + +#flashcards/folder1 #flashcards/folder2 +Q11::A11 +Q12::A12 +Q13::A13 +Q14::A14 +Chemistry Question from file underdog 4B::goodby + +Chemistry Question from file underdog 4C::goodby + +This single {{question}} turns into {{3 separate}} {{cards}} + + +#flashcards/science/misc + + `; + const actual: TagCache[] = unitTest_GetAllTagsFromTextEx(text); + const expected: TagCache[] = [ + createTagCacheObj("#review", 2), + createTagCacheObj("#flashcards/science/chemistry", 5), + createTagCacheObj("#flashcards/science/misc", 18), + ]; + expect(actual).toEqual(expected); + }); + + test("Multiple tags on same line", () => { + // The next line is numbered as line 0, therefore #review is line 2 + const text: string = ` + +#flashcards/science/chemistry #flashcards/science/misc + + + + `; + const actual: TagCache[] = unitTest_GetAllTagsFromTextEx(text); + const expected: TagCache[] = [ + createTagCacheObj("#flashcards/science/chemistry", 2), + createTagCacheObj("#flashcards/science/misc", 2), + ]; + expect(actual).toEqual(expected); + }); + }); +}); + +function createTagCacheObj(tag: string, line: number): any { + return { + tag: tag, + position: { + start: { line: line, col: null, offset: null }, + end: { line: line, col: null, offset: null }, + }, + }; +} diff --git a/tests/unit/helpers/UnitTestHelper.ts b/tests/unit/helpers/UnitTestHelper.ts new file mode 100644 index 00000000..e87c8420 --- /dev/null +++ b/tests/unit/helpers/UnitTestHelper.ts @@ -0,0 +1,64 @@ +import { TagCache } from "obsidian"; +import { extractFrontmatter, splitTextIntoLineArray } from "src/util/utils"; + +export function unitTest_CreateTagCache(tag: string, lineNum: number): TagCache { + return { + tag, + position: { + start: { line: lineNum, col: null, offset: null }, + end: { line: lineNum, col: null, offset: null }, + }, + }; +} + +export function unitTest_GetAllTagsFromTextEx(text: string): TagCache[] { + const [frontmatter, content] = extractFrontmatter(text); + const result = [] as TagCache[]; + let lines: string[]; + + if (frontmatter) { + const dataPrefix: string = " - "; + lines = splitTextIntoLineArray(frontmatter); + let foundTagHeading: boolean = false; + for (let i = 0; i < lines.length; i++) { + const line: string = lines[i]; + if (foundTagHeading) { + if (line.startsWith(dataPrefix)) { + const tagStr: string = line.substring(dataPrefix.length); + result.push(unitTest_CreateTagCache("#" + tagStr, i)); + } else { + break; + } + } else { + if (line.startsWith("tags:")) { + foundTagHeading = true; + } + } + } + } + lines = splitTextIntoLineArray(text); + for (let i = 0; i < lines.length; i++) { + const tagRegex = /#[^\s#]+/gi; + const matchList: RegExpMatchArray = lines[i].match(tagRegex); + if (matchList) { + for (const match of matchList) { + const tag: TagCache = { + tag: match, + position: { + start: { line: i, col: null, offset: null }, + end: { line: i, col: null, offset: null }, + }, + }; + result.push(tag); + } + } + } + return result; +} + +export function unitTest_GetAllTagsFromText(text: string): string[] { + const tagRegex = /#[^\s#]+/gi; + const result: RegExpMatchArray = text.match(tagRegex); + if (!result) return []; + return result; +} diff --git a/tests/unit/helpers/UnitTestSRFile.ts b/tests/unit/helpers/UnitTestSRFile.ts new file mode 100644 index 00000000..3110f892 --- /dev/null +++ b/tests/unit/helpers/UnitTestSRFile.ts @@ -0,0 +1,38 @@ +import { TagCache } from "obsidian"; +import { ISRFile } from "src/SRFile"; +import { unitTest_GetAllTagsFromTextEx } from "./UnitTestHelper"; + +export class UnitTestSRFile implements ISRFile { + content: string; + _path: string; + + constructor(content: string, path: string = null) { + this.content = content; + this._path = path; + } + + get path(): string { + return this._path; + } + + get basename(): string { + return ""; + } + + getAllTagsFromText(): TagCache[] { + return unitTest_GetAllTagsFromTextEx(this.content); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getQuestionContext(cardLine: number): string[] { + return []; + } + + async read(): Promise { + return this.content; + } + + async write(content: string): Promise { + this.content = content; + } +} diff --git a/tests/unit/parser.test.ts b/tests/unit/parser.test.ts index 183dc679..9d2c7b6f 100644 --- a/tests/unit/parser.test.ts +++ b/tests/unit/parser.test.ts @@ -1,4 +1,4 @@ -import { parse } from "src/parser"; +import { parseEx, ParsedQuestionInfo } from "src/parser"; import { CardType } from "src/Question"; const defaultArgs: [string, string, string, string, boolean, boolean, boolean] = [ @@ -11,83 +11,125 @@ const defaultArgs: [string, string, string, string, boolean, boolean, boolean] = true, ]; +/** + * This function is a small wrapper around parseEx used for testing only. + * Created when the actual parser changed from returning [CardType, string, number, number] to ParsedQuestionInfo. + * It's purpose is to minimise changes to all the test cases here during the parser()->parserEx() change. + */ +function parse( + text: string, + singlelineCardSeparator: string, + singlelineReversedCardSeparator: string, + multilineCardSeparator: string, + multilineReversedCardSeparator: string, + convertHighlightsToClozes: boolean, + convertBoldTextToClozes: boolean, + convertCurlyBracketsToClozes: boolean, +): [CardType, string, number, number][] { + const list: ParsedQuestionInfo[] = parseEx( + text, + singlelineCardSeparator, + singlelineReversedCardSeparator, + multilineCardSeparator, + multilineReversedCardSeparator, + convertHighlightsToClozes, + convertBoldTextToClozes, + convertCurlyBracketsToClozes, + ); + const result: [CardType, string, number, number][] = []; + for (const item of list) { + result.push([item.cardType, item.text, item.firstLineNum, item.lastLineNum]); + } + return result; +} + test("Test parsing of single line basic cards", () => { expect(parse("Question::Answer", ...defaultArgs)).toEqual([ - [CardType.SingleLineBasic, "Question::Answer", 0], + [CardType.SingleLineBasic, "Question::Answer", 0, 0], ]); expect(parse("Question::Answer\n", ...defaultArgs)).toEqual([ - [CardType.SingleLineBasic, "Question::Answer\n", 0], + [CardType.SingleLineBasic, "Question::Answer\n", 0, 1], ]); expect(parse("Question::Answer ", ...defaultArgs)).toEqual([ - [CardType.SingleLineBasic, "Question::Answer ", 0], + [CardType.SingleLineBasic, "Question::Answer ", 0, 0], ]); expect(parse("Some text before\nQuestion ::Answer", ...defaultArgs)).toEqual([ - [CardType.SingleLineBasic, "Question ::Answer", 1], + [CardType.SingleLineBasic, "Question ::Answer", 1, 1], ]); expect(parse("#Title\n\nQ1::A1\nQ2:: A2", ...defaultArgs)).toEqual([ - [CardType.SingleLineBasic, "Q1::A1", 2], - [CardType.SingleLineBasic, "Q2:: A2", 3], + [CardType.SingleLineBasic, "Q1::A1", 2, 2], + [CardType.SingleLineBasic, "Q2:: A2", 3, 3], ]); expect(parse("#flashcards/science Question ::Answer", ...defaultArgs)).toEqual([ - [CardType.SingleLineBasic, "#flashcards/science Question ::Answer", 0], + [CardType.SingleLineBasic, "#flashcards/science Question ::Answer", 0, 0], ]); }); test("Test parsing of single line reversed cards", () => { expect(parse("Question:::Answer", ...defaultArgs)).toEqual([ - [CardType.SingleLineReversed, "Question:::Answer", 0], + [CardType.SingleLineReversed, "Question:::Answer", 0, 0], ]); expect(parse("Some text before\nQuestion :::Answer", ...defaultArgs)).toEqual([ - [CardType.SingleLineReversed, "Question :::Answer", 1], + [CardType.SingleLineReversed, "Question :::Answer", 1, 1], ]); expect(parse("#Title\n\nQ1:::A1\nQ2::: A2", ...defaultArgs)).toEqual([ - [CardType.SingleLineReversed, "Q1:::A1", 2], - [CardType.SingleLineReversed, "Q2::: A2", 3], + [CardType.SingleLineReversed, "Q1:::A1", 2, 2], + [CardType.SingleLineReversed, "Q2::: A2", 3, 3], ]); }); test("Test parsing of multi line basic cards", () => { expect(parse("Question\n?\nAnswer", ...defaultArgs)).toEqual([ - [CardType.MultiLineBasic, "Question\n?\nAnswer", 1], + [CardType.MultiLineBasic, "Question\n?\nAnswer", 0, 2], ]); expect(parse("Question\n? \nAnswer", ...defaultArgs)).toEqual([ - [CardType.MultiLineBasic, "Question\n?\nAnswer", 1], + [CardType.MultiLineBasic, "Question\n?\nAnswer", 0, 2], ]); expect(parse("Question\n?\nAnswer ", ...defaultArgs)).toEqual([ - [CardType.MultiLineBasic, "Question\n?\nAnswer ", 1], + [CardType.MultiLineBasic, "Question\n?\nAnswer ", 0, 2], ]); expect(parse("Question\n?\nAnswer\n", ...defaultArgs)).toEqual([ - [CardType.MultiLineBasic, "Question\n?\nAnswer\n", 1], + [CardType.MultiLineBasic, "Question\n?\nAnswer\n", 0, 3], ]); - expect(parse("Some text before\nQuestion\n?\nAnswer", ...defaultArgs)).toEqual([ - [CardType.MultiLineBasic, "Some text before\nQuestion\n?\nAnswer", 2], + expect(parse("Question line 1\nQuestion line 2\n?\nAnswer", ...defaultArgs)).toEqual([ + [CardType.MultiLineBasic, "Question line 1\nQuestion line 2\n?\nAnswer", 0, 3], ]); - expect(parse("Question\n?\nAnswer\nSome text after!", ...defaultArgs)).toEqual([ - [CardType.MultiLineBasic, "Question\n?\nAnswer\nSome text after!", 1], + expect(parse("Question\n?\nAnswer line 1\nAnswer line 2", ...defaultArgs)).toEqual([ + [CardType.MultiLineBasic, "Question\n?\nAnswer line 1\nAnswer line 2", 0, 3], ]); expect(parse("#Title\n\nLine0\nQ1\n?\nA1\nAnswerExtra\n\nQ2\n?\nA2", ...defaultArgs)).toEqual([ - [CardType.MultiLineBasic, "Line0\nQ1\n?\nA1\nAnswerExtra", 4], - [CardType.MultiLineBasic, "Q2\n?\nA2", 9], + [ + CardType.MultiLineBasic, + "Line0\nQ1\n?\nA1\nAnswerExtra", + /* Line0 */ 2, + /* AnswerExtra */ 6, + ], + [CardType.MultiLineBasic, "Q2\n?\nA2", 8, 10], ]); expect(parse("#flashcards/tag-on-previous-line\nQuestion\n?\nAnswer", ...defaultArgs)).toEqual([ - [CardType.MultiLineBasic, "#flashcards/tag-on-previous-line\nQuestion\n?\nAnswer", 2], + [CardType.MultiLineBasic, "#flashcards/tag-on-previous-line\nQuestion\n?\nAnswer", 0, 3], ]); }); test("Test parsing of multi line reversed cards", () => { expect(parse("Question\n??\nAnswer", ...defaultArgs)).toEqual([ - [CardType.MultiLineReversed, "Question\n??\nAnswer", 1], + [CardType.MultiLineReversed, "Question\n??\nAnswer", 0, 2], ]); - expect(parse("Some text before\nQuestion\n??\nAnswer", ...defaultArgs)).toEqual([ - [CardType.MultiLineReversed, "Some text before\nQuestion\n??\nAnswer", 2], + expect(parse("Question line 1\nQuestion line 2\n??\nAnswer", ...defaultArgs)).toEqual([ + [CardType.MultiLineReversed, "Question line 1\nQuestion line 2\n??\nAnswer", 0, 3], ]); - expect(parse("Question\n??\nAnswer\nSome text after!", ...defaultArgs)).toEqual([ - [CardType.MultiLineReversed, "Question\n??\nAnswer\nSome text after!", 1], + expect(parse("Question\n??\nAnswer line 1\nAnswer line 2", ...defaultArgs)).toEqual([ + [CardType.MultiLineReversed, "Question\n??\nAnswer line 1\nAnswer line 2", 0, 3], ]); expect(parse("#Title\n\nLine0\nQ1\n??\nA1\nAnswerExtra\n\nQ2\n??\nA2", ...defaultArgs)).toEqual( [ - [CardType.MultiLineReversed, "Line0\nQ1\n??\nA1\nAnswerExtra", 4], - [CardType.MultiLineReversed, "Q2\n??\nA2", 9], + [ + CardType.MultiLineReversed, + "Line0\nQ1\n??\nA1\nAnswerExtra", + /* Line0 */ 2, + /* AnswerExtra */ 6, + ], + [CardType.MultiLineReversed, "Q2\n??\nA2", 8, 10], ], ); }); @@ -95,16 +137,16 @@ test("Test parsing of multi line reversed cards", () => { test("Test parsing of cloze cards", () => { // ==highlights== expect(parse("cloze ==deletion== test", ...defaultArgs)).toEqual([ - [CardType.Cloze, "cloze ==deletion== test", 0], + [CardType.Cloze, "cloze ==deletion== test", 0, 0], ]); expect(parse("cloze ==deletion== test\n", ...defaultArgs)).toEqual([ - [CardType.Cloze, "cloze ==deletion== test\n", 0], + [CardType.Cloze, "cloze ==deletion== test\n", 0, 1], ]); expect(parse("cloze ==deletion== test ", ...defaultArgs)).toEqual([ - [CardType.Cloze, "cloze ==deletion== test ", 0], + [CardType.Cloze, "cloze ==deletion== test ", 0, 0], ]); expect(parse("==this== is a ==deletion==\n", ...defaultArgs)).toEqual([ - [CardType.Cloze, "==this== is a ==deletion==", 0], + [CardType.Cloze, "==this== is a ==deletion==", 0, 0], ]); expect( parse( @@ -113,8 +155,8 @@ test("Test parsing of cloze cards", () => { ...defaultArgs, ), ).toEqual([ - [CardType.Cloze, "a deletion on\nsuch ==wow==", 3], - [CardType.Cloze, "many text\nsuch surprise ==wow== more ==text==\nsome text after", 6], + [CardType.Cloze, "a deletion on\nsuch ==wow==", 2, 3], + [CardType.Cloze, "many text\nsuch surprise ==wow== more ==text==\nsome text after", 5, 7], ]); expect(parse("srdf ==", ...defaultArgs)).toEqual([]); expect(parse("lorem ipsum ==p\ndolor won==", ...defaultArgs)).toEqual([]); @@ -126,16 +168,16 @@ test("Test parsing of cloze cards", () => { // **bolded** expect(parse("cloze **deletion** test", ...defaultArgs)).toEqual([ - [CardType.Cloze, "cloze **deletion** test", 0], + [CardType.Cloze, "cloze **deletion** test", 0, 0], ]); expect(parse("cloze **deletion** test\n", ...defaultArgs)).toEqual([ - [CardType.Cloze, "cloze **deletion** test\n", 0], + [CardType.Cloze, "cloze **deletion** test\n", 0, 1], ]); expect(parse("cloze **deletion** test ", ...defaultArgs)).toEqual([ - [CardType.Cloze, "cloze **deletion** test ", 0], + [CardType.Cloze, "cloze **deletion** test ", 0, 0], ]); expect(parse("**this** is a **deletion**\n", ...defaultArgs)).toEqual([ - [CardType.Cloze, "**this** is a **deletion**", 0], + [CardType.Cloze, "**this** is a **deletion**", 0, 0], ]); expect( parse( @@ -144,8 +186,8 @@ test("Test parsing of cloze cards", () => { ...defaultArgs, ), ).toEqual([ - [CardType.Cloze, "a deletion on\nsuch **wow**", 3], - [CardType.Cloze, "many text\nsuch surprise **wow** more **text**\nsome text after", 6], + [CardType.Cloze, "a deletion on\nsuch **wow**", 2, 3], + [CardType.Cloze, "many text\nsuch surprise **wow** more **text**\nsome text after", 5, 7], ]); expect(parse("srdf **", ...defaultArgs)).toEqual([]); expect(parse("lorem ipsum **p\ndolor won**", ...defaultArgs)).toEqual([]); @@ -157,7 +199,7 @@ test("Test parsing of cloze cards", () => { // both expect(parse("cloze **deletion** test ==another deletion==!", ...defaultArgs)).toEqual([ - [CardType.Cloze, "cloze **deletion** test ==another deletion==!", 0], + [CardType.Cloze, "cloze **deletion** test ==another deletion==!", 0, 0], ]); }); @@ -177,12 +219,14 @@ test("Test parsing of a mix of card types", () => { "Duis magna arcu, eleifend rhoncus ==euismod non,==\n" + "laoreet vitae enim.", 2, + 4, ], - [CardType.SingleLineBasic, "Fusce placerat::velit in pharetra gravida", 6], + [CardType.SingleLineBasic, "Fusce placerat::velit in pharetra gravida", 6, 6], [ CardType.MultiLineReversed, "Donec dapibus ullamcorper aliquam.\n??\nDonec dapibus ullamcorper aliquam.\n", - 9, + 8, + 11 /* */, ], ]); }); @@ -200,7 +244,8 @@ test("Test codeblocks", () => { CardType.MultiLineBasic, "How do you ... Python?\n?\n" + "```\nprint('Hello World!')\nprint('Howdy?')\nlambda x: x[0]\n```", - 1, + 0, + 6 /* ``` */, ], ]); @@ -216,7 +261,8 @@ test("Test codeblocks", () => { CardType.MultiLineBasic, "How do you ... Python?\n?\n" + "```\nprint('Hello World!')\n\n\nprint('Howdy?')\n\nlambda x: x[0]\n```", - 1, + 0, + 9 /* ``` */, ], ]); @@ -248,7 +294,8 @@ test("Test codeblocks", () => { "print('hello world')\n" + "~~~\n" + "````", - 1, + 0, + 12 /* ``` */, ], ]); }); diff --git a/tests/unit/util/utils.test.ts b/tests/unit/util/utils.test.ts index 36a797e6..b4b79e62 100644 --- a/tests/unit/util/utils.test.ts +++ b/tests/unit/util/utils.test.ts @@ -1,5 +1,9 @@ import { YAML_FRONT_MATTER_REGEX } from "src/constants"; -import { findLineIndexOfSearchStringIgnoringWs, literalStringReplace } from "src/util/utils"; +import { + extractFrontmatter, + findLineIndexOfSearchStringIgnoringWs, + literalStringReplace, +} from "src/util/utils"; describe("literalStringReplace", () => { test("Replacement string doesn't have any dollar signs", async () => { @@ -92,6 +96,71 @@ describe("YAML_FRONT_MATTER_REGEX", () => { }); }); +describe("extractFrontmatter", () => { + test("No frontmatter", () => { + let text: string = `Hello +Goodbye`; + let frontmatter: string; + let content: string; + [frontmatter, content] = extractFrontmatter(text); + expect(frontmatter).toEqual(""); + expect(content).toEqual(text); + + text = `--- +Goodbye`; + [frontmatter, content] = extractFrontmatter(text); + expect(frontmatter).toEqual(""); + expect(content).toEqual(text); + }); + + test("With frontmatter (and nothing else)", () => { + let frontmatter: string = `--- +sr-due: 2024-01-17 +sr-interval: 16 +sr-ease: 278 +tags: + - flashcards/aws + - flashcards/datascience +---`; + const text: string = frontmatter; + let content: string; + [frontmatter, content] = extractFrontmatter(text); + expect(frontmatter).toEqual(text); + expect(content).toEqual(""); + }); + + test("With frontmatter (and content)", () => { + let frontmatter: string = `--- +sr-due: 2024-01-17 +sr-interval: 16 +sr-ease: 278 +tags: + - flashcards/aws + - flashcards/datascience +---`; + const content: string = `#flashcards/science/chemistry + +# Questions + +Chemistry Question from file underelephant 4A::goodby + +Chemistry Question from file underdog 4B::goodby + +Chemistry Question from file underdog 4C::goodby + +This single {{question}} turns into {{3 separate}} {{cards}} + + +`; + const text: string = `${frontmatter} +${content}`; + + const [f, c] = extractFrontmatter(text); + expect(f).toEqual(frontmatter); + expect(c).toEqual(content); + }); +}); + describe("findLineIndexOfSearchStringIgnoringWs", () => { const space: string = " "; test("Search string not present", () => {