From 5e8f98859ad9fa62616e00b24758ce9355f58279 Mon Sep 17 00:00:00 2001 From: Ronny Zulaikha <75528127+ronzulu@users.noreply.github.com> Date: Wed, 4 Oct 2023 22:21:48 +1100 Subject: [PATCH] refactor: Separate business logic from user interface code (#751) * First commit * This doesn't build yet * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Something * Added support for burySiblingCards * Fixed the card count displayed on the status bar * Minor * Minor * Refactoring in preparation for random DeckTreeIter * Support random sequencing of cards during review * Fixed statistics charts * after npm run format * Code for updating question text made resilient to whitespace differences * Added more test cases * Added test case * Fixes for certain cases of whitespace following a question's topic tag * npm run format; update version in manifest.json * Support the Enter key on the numeric keypad in the shortcuts #706 * In flashcard modal, allow user to click across entire tree item rectangle https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/709 * npm run format; temporary filename changes * Updated formatting for lint, file rename * fix issues reported by pnpm lint * Restored version number to "1.10.1" * Restored pnpm-lock.yaml from version 1.10.1 * Updated change log to reference #706, and #709 * Fixing capitalization of "Note.ts" (step 1) * Fixing capitalization of "Note.ts" step #2 * Reduced collectCoverageFrom to subset of files with 100% code coverage --- CHANGELOG.md | 2 +- CONTRIBUTING.md | 2 +- docs/changelog.md | 17 + jest.config.js | 12 +- src/Card.ts | 41 + src/CardSchedule.ts | 168 +++ src/Deck.ts | 226 ++++ src/DeckTreeIterator.ts | 231 ++++ src/DeckTreeStatsCalculator.ts | 41 + src/FlashcardReviewSequencer.ts | 188 ++++ src/Note.ts | 51 + src/NoteEaseCalculator.ts | 31 + src/NoteEaseList.ts | 36 + src/NoteFileLoader.ts | 32 + src/NoteParser.ts | 22 + src/NoteQuestionParser.ts | 147 +++ src/Question.ts | 223 ++++ src/QuestionPostponementList.ts | 38 + src/QuestionType.ts | 171 +++ src/{review-deck.ts => ReviewDeck.ts} | 0 src/SRFile.ts | 109 ++ src/TopicPath.ts | 120 +++ src/constants.ts | 8 + src/flashcard-modal.tsx | 1040 ------------------- src/gui/flashcard-modal.tsx | 515 +++++++++ src/gui/flashcards-edit-modal.ts | 117 +++ src/{ => gui}/sidebar.ts | 2 +- src/{ => gui}/stats-modal.tsx | 61 +- src/main.ts | 538 +++------- src/parser.ts | 2 +- src/scheduling.ts | 32 - src/stats.ts | 42 + src/util/DateProvider.ts | 41 + src/util/MultiLineTextFinder.ts | 45 + src/util/NumberCountDict.ts | 30 + src/util/RandomNumberProvider.ts | 40 + src/util/RenderMarkdownWrapper.ts | 145 +++ src/{ => util}/utils.ts | 25 + tests/unit/DeckTreeIterator.test.ts | 497 +++++++++ tests/unit/FlashcardReviewSequencer.test.ts | 825 +++++++++++++++ tests/unit/Note.test.ts | 76 ++ tests/unit/NoteCardScheduleParser.test.ts | 43 + tests/unit/NoteEaseList.test.ts | 22 + tests/unit/NoteFileLoader.test.ts | 35 + tests/unit/NoteParser.test.ts | 28 + tests/unit/NoteQuestionParser.test.ts | 352 +++++++ tests/unit/Question.test.ts | 34 + tests/unit/QuestionType.test.ts | 98 ++ tests/unit/SampleItems.ts | 57 + tests/unit/TopicPath.test.ts | 335 ++++++ tests/unit/deck.test.ts | 286 +++++ tests/unit/parser.test.ts | 8 +- tests/unit/util/MultiLineTextFinder.test.ts | 180 ++++ 53 files changed, 5969 insertions(+), 1498 deletions(-) create mode 100644 src/Card.ts create mode 100644 src/CardSchedule.ts create mode 100644 src/Deck.ts create mode 100644 src/DeckTreeIterator.ts create mode 100644 src/DeckTreeStatsCalculator.ts create mode 100644 src/FlashcardReviewSequencer.ts create mode 100644 src/Note.ts create mode 100644 src/NoteEaseCalculator.ts create mode 100644 src/NoteEaseList.ts create mode 100644 src/NoteFileLoader.ts create mode 100644 src/NoteParser.ts create mode 100644 src/NoteQuestionParser.ts create mode 100644 src/Question.ts create mode 100644 src/QuestionPostponementList.ts create mode 100644 src/QuestionType.ts rename src/{review-deck.ts => ReviewDeck.ts} (100%) create mode 100644 src/SRFile.ts create mode 100644 src/TopicPath.ts delete mode 100644 src/flashcard-modal.tsx create mode 100644 src/gui/flashcard-modal.tsx create mode 100644 src/gui/flashcards-edit-modal.ts rename src/{ => gui}/sidebar.ts (99%) rename src/{ => gui}/stats-modal.tsx (83%) create mode 100644 src/stats.ts create mode 100644 src/util/DateProvider.ts create mode 100644 src/util/MultiLineTextFinder.ts create mode 100644 src/util/NumberCountDict.ts create mode 100644 src/util/RandomNumberProvider.ts create mode 100644 src/util/RenderMarkdownWrapper.ts rename src/{ => util}/utils.ts (74%) create mode 100644 tests/unit/DeckTreeIterator.test.ts create mode 100644 tests/unit/FlashcardReviewSequencer.test.ts create mode 100644 tests/unit/Note.test.ts create mode 100644 tests/unit/NoteCardScheduleParser.test.ts create mode 100644 tests/unit/NoteEaseList.test.ts create mode 100644 tests/unit/NoteFileLoader.test.ts create mode 100644 tests/unit/NoteParser.test.ts create mode 100644 tests/unit/NoteQuestionParser.test.ts create mode 100644 tests/unit/Question.test.ts create mode 100644 tests/unit/QuestionType.test.ts create mode 100644 tests/unit/SampleItems.ts create mode 100644 tests/unit/TopicPath.test.ts create mode 100644 tests/unit/deck.test.ts create mode 100644 tests/unit/util/MultiLineTextFinder.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bed66b3..1cc39a66 120000 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -docs/changelog.md \ No newline at end of file +docs/changelog.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 651dc17d..9815d5bd 120000 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1 @@ -docs/en/contributing.md \ No newline at end of file +docs/en/contributing.md diff --git a/docs/changelog.md b/docs/changelog.md index ff7e5aef..7ca1d5ee 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +### [Unreleased] + +- [Fixed] Enable clicking anywhere within deck item (not just deck name) in flashcard modal's deck tree [`#709`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/709) +- [Added] Support the Enter key on the numeric keypad [`#706`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/706) + +### [Unreleased] + +- This has not yet been approved by the project owner (https://github.com/st3v3nmw/obsidian-spaced-repetition) +- It is available at (https://github.com/ronzulu/obsidian-spaced-repetition) +- I want to make it available prior to the code being merged into the master branch by the project owner + +- [Changed] Separated out "business logic" from the user interface code for flashcards, as well as other refactoring +- Intention to keep existing functionality without change +- Development: Thoughts about implementation redesign? [`#736`](https://github.com/st3v3nmw/obsidian-spaced-repetition/discussions/736) +- [Changed] Added many unit test cases - now around 170 (up from 20) +- [Fixed] Revisons cannot be saved properly [`#745`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/745) + #### [1.10.1](https://github.com/st3v3nmw/obsidian-spaced-repetition/compare/1.10.0...1.10.1) - style: Fix formatting [`#678`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/678) diff --git a/jest.config.js b/jest.config.js index 34842b2c..63690224 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,7 +9,17 @@ module.exports = { }, moduleFileExtensions: ["js", "jsx", "ts", "tsx", "json", "node", "d.ts"], roots: ["/src/", "/tests/unit/"], - collectCoverageFrom: ["src/**/lang/*.ts", "src/parser.ts", "src/scheduling.ts", "utils.ts"], + collectCoverageFrom: [ + "src/**/lang/*.ts", + "src/NoteEaseList.ts", + "src/NoteFileLoader.ts", + "src/NoteParser.ts", + "src/NoteQuestionParser.ts", + "src/TopicParser.ts", + "src/parser.ts", + "src/scheduling.ts", + "utils.ts", + ], coveragePathIgnorePatterns: [ "/node_modules/", "src/lang/locale/", diff --git a/src/Card.ts b/src/Card.ts new file mode 100644 index 00000000..b95ce6c6 --- /dev/null +++ b/src/Card.ts @@ -0,0 +1,41 @@ +import { Question } from "./Question"; +import { CardScheduleInfo } from "./CardSchedule"; +import { CardListType } from "./Deck"; + +export class Card { + question: Question; + cardIdx: number; + + // scheduling + get hasSchedule(): boolean { + return this.scheduleInfo != null; + } + scheduleInfo?: CardScheduleInfo; + + // visuals + front: string; + back: string; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + get cardListType(): CardListType { + return this.hasSchedule ? CardListType.DueCard : CardListType.NewCard; + } + + get isNew(): boolean { + return !this.hasSchedule; + } + + get isDue(): boolean { + return this.hasSchedule && this.scheduleInfo.isDue(); + } + + formatSchedule(): string { + let result: string = ""; + if (this.hasSchedule) result = this.scheduleInfo.formatSchedule(); + else result = "New"; + return result; + } +} diff --git a/src/CardSchedule.ts b/src/CardSchedule.ts new file mode 100644 index 00000000..12403ab4 --- /dev/null +++ b/src/CardSchedule.ts @@ -0,0 +1,168 @@ +import { Moment } from "moment"; +import { + LEGACY_SCHEDULING_EXTRACTOR, + MULTI_SCHEDULING_EXTRACTOR, + TICKS_PER_DAY, +} from "./constants"; +import { INoteEaseList } from "./NoteEaseList"; +import { ReviewResponse, schedule } from "./scheduling"; +import { SRSettings } from "./settings"; +import { formatDate_YYYY_MM_DD } from "./util/utils"; +import { DateUtil, globalDateProvider } from "./util/DateProvider"; + +export class CardScheduleInfo { + dueDate: Moment; + interval: number; + ease: number; + delayBeforeReviewTicks: number; + + constructor(dueDate: Moment, interval: number, ease: number, delayBeforeReviewTicks: number) { + this.dueDate = dueDate; + this.interval = interval; + this.ease = ease; + this.delayBeforeReviewTicks = delayBeforeReviewTicks; + } + + get delayBeforeReviewDaysInt(): number { + return Math.ceil(this.delayBeforeReviewTicks / TICKS_PER_DAY); + } + + isDue(): boolean { + return this.dueDate.isSameOrBefore(globalDateProvider.today); + } + + static getDummySchedule(settings: SRSettings): CardScheduleInfo { + return CardScheduleInfo.fromDueDateStr( + "2000-01-01", + CardScheduleInfo.initialInterval, + settings.baseEase, + 0, + ); + } + + static fromDueDateStr( + dueDateStr: string, + interval: number, + ease: number, + delayBeforeReviewTicks: number, + ) { + const dueDateTicks: Moment = DateUtil.dateStrToMoment(dueDateStr); + return new CardScheduleInfo(dueDateTicks, interval, ease, delayBeforeReviewTicks); + } + + static fromDueDateMoment( + dueDateTicks: Moment, + interval: number, + ease: number, + delayBeforeReviewTicks: number, + ) { + return new CardScheduleInfo(dueDateTicks, interval, ease, delayBeforeReviewTicks); + } + + static get initialInterval(): number { + return 1.0; + } + + formatDueDate(): string { + return formatDate_YYYY_MM_DD(this.dueDate); + } + + formatSchedule() { + return `!${this.formatDueDate()},${this.interval},${this.ease}`; + } +} + +export interface ICardScheduleCalculator { + getResetCardSchedule(): CardScheduleInfo; + getNewCardSchedule(response: ReviewResponse, notePath: string): CardScheduleInfo; + calcUpdatedSchedule(response: ReviewResponse, schedule: CardScheduleInfo): CardScheduleInfo; +} + +export class CardScheduleCalculator { + settings: SRSettings; + noteEaseList: INoteEaseList; + dueDatesFlashcards: Record = {}; // Record<# of days in future, due count> + + constructor(settings: SRSettings, noteEaseList: INoteEaseList) { + this.settings = settings; + this.noteEaseList = noteEaseList; + } + + getResetCardSchedule(): CardScheduleInfo { + const interval = CardScheduleInfo.initialInterval; + const ease = this.settings.baseEase; + const dueDate = globalDateProvider.today.add(interval, "d"); + const delayBeforeReview = 0; + return CardScheduleInfo.fromDueDateMoment(dueDate, interval, ease, delayBeforeReview); + } + + getNewCardSchedule(response: ReviewResponse, notePath: string): CardScheduleInfo { + const initial_ease: number = this.noteEaseList.getEaseByPath(notePath); + const delayBeforeReview = 0; + + const schedObj: Record = schedule( + response, + CardScheduleInfo.initialInterval, + initial_ease, + delayBeforeReview, + this.settings, + this.dueDatesFlashcards, + ); + + const interval = schedObj.interval; + const ease = schedObj.ease; + const dueDate = globalDateProvider.today.add(interval, "d"); + return CardScheduleInfo.fromDueDateMoment(dueDate, interval, ease, delayBeforeReview); + } + + calcUpdatedSchedule( + response: ReviewResponse, + cardSchedule: CardScheduleInfo, + ): CardScheduleInfo { + const schedObj: Record = schedule( + response, + cardSchedule.interval, + cardSchedule.ease, + cardSchedule.delayBeforeReviewTicks, + this.settings, + this.dueDatesFlashcards, + ); + const interval = schedObj.interval; + const ease = schedObj.ease; + const dueDate = globalDateProvider.today.add(interval, "d"); + const delayBeforeReview = 0; + return CardScheduleInfo.fromDueDateMoment(dueDate, interval, ease, delayBeforeReview); + } +} + +export class NoteCardScheduleParser { + static createCardScheduleInfoList(questionText: string): CardScheduleInfo[] { + let scheduling: RegExpMatchArray[] = [...questionText.matchAll(MULTI_SCHEDULING_EXTRACTOR)]; + if (scheduling.length === 0) + scheduling = [...questionText.matchAll(LEGACY_SCHEDULING_EXTRACTOR)]; + + const result: CardScheduleInfo[] = []; + for (let i = 0; i < scheduling.length; i++) { + const match: RegExpMatchArray = scheduling[i]; + const dueDateStr = match[1]; + const interval = parseInt(match[2]); + const ease = parseInt(match[3]); + const dueDate: Moment = DateUtil.dateStrToMoment(dueDateStr); + const delayBeforeReviewTicks: number = + dueDate.valueOf() - globalDateProvider.today.valueOf(); + + const info: CardScheduleInfo = new CardScheduleInfo( + dueDate, + interval, + ease, + delayBeforeReviewTicks, + ); + result.push(info); + } + return result; + } + + static removeCardScheduleInfo(questionText: string): string { + return questionText.replace(//gm, ""); + } +} diff --git a/src/Deck.ts b/src/Deck.ts new file mode 100644 index 00000000..fceecb56 --- /dev/null +++ b/src/Deck.ts @@ -0,0 +1,226 @@ +import { Card } from "./Card"; +import { FlashcardReviewMode } from "./FlashcardReviewSequencer"; +import { Question } from "./Question"; +import { IQuestionPostponementList } from "./QuestionPostponementList"; +import { TopicPath } from "./TopicPath"; + +export enum CardListType { + NewCard, + DueCard, + All, +} + +export class Deck { + public deckName: string; + public newFlashcards: Card[]; + public dueFlashcards: Card[]; + public subdecks: Deck[]; + public parent: Deck | null; + + public getCardCount(cardListType: CardListType, includeSubdeckCounts: boolean): number { + let result: number = 0; + if (cardListType == CardListType.NewCard || cardListType == CardListType.All) + result += this.newFlashcards.length; + if (cardListType == CardListType.DueCard || cardListType == CardListType.All) + result += this.dueFlashcards.length; + + if (includeSubdeckCounts) { + for (const deck of this.subdecks) { + result += deck.getCardCount(cardListType, includeSubdeckCounts); + } + } + return result; + } + + constructor(deckName: string, parent: Deck | null) { + this.deckName = deckName; + this.newFlashcards = []; + this.dueFlashcards = []; + this.subdecks = []; + this.parent = parent; + } + + static get emptyDeck(): Deck { + return new Deck("Root", null); + } + + get isRootDeck() { + return this.parent == null; + } + + getDeck(topicPath: TopicPath): Deck { + return this._getOrCreateDeck(topicPath, false); + } + + getOrCreateDeck(topicPath: TopicPath): Deck { + return this._getOrCreateDeck(topicPath, true); + } + + private _getOrCreateDeck(topicPath: TopicPath, createAllowed: boolean): Deck { + if (!topicPath.hasPath) { + return this; + } + const t: TopicPath = topicPath.clone(); + const deckName: string = t.shift(); + for (const subdeck of this.subdecks) { + if (deckName === subdeck.deckName) { + return subdeck._getOrCreateDeck(t, createAllowed); + } + } + + let result: Deck = null; + if (createAllowed) { + const subdeck: Deck = new Deck(deckName, this /* parent */); + this.subdecks.push(subdeck); + result = subdeck._getOrCreateDeck(t, createAllowed); + } + return result; + } + + getTopicPath(): TopicPath { + const list: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-this-alias + let deck: Deck = this; + while (!deck.isRootDeck) { + list.push(deck.deckName); + deck = deck.parent; + } + return new TopicPath(list.reverse()); + } + + getRootDeck(): Deck { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let deck: Deck = this; + while (!deck.isRootDeck) { + deck = deck.parent; + } + return deck; + } + + getCard(index: number, cardListType: CardListType): Card { + const cardList: Card[] = this.getCardListForCardType(cardListType); + return cardList[index]; + } + + getCardListForCardType(cardListType: CardListType): Card[] { + return cardListType == CardListType.DueCard ? this.dueFlashcards : this.newFlashcards; + } + + appendCard(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); + } + + deleteCardAtIndex(index: number, cardListType: CardListType): void { + const cardList: Card[] = this.getCardListForCardType(cardListType); + cardList.splice(index, 1); + } + + deleteAllCardsForQuestion(question: Question): void { + for (let idx = question.cards.length - 1; idx >= 0; idx--) { + this.deleteCardAtIndex(idx, question.cards[idx].cardListType); + } + } + + toDeckArray(): Deck[] { + const result: Deck[] = []; + result.push(this); + for (const subdeck of this.subdecks) { + result.push(...subdeck.toDeckArray()); + } + return result; + } + + sortSubdecksList(): void { + this.subdecks.sort((a, b) => { + if (a.deckName < b.deckName) { + return -1; + } else if (a.deckName > b.deckName) { + return 1; + } + return 0; + }); + + for (const deck of this.subdecks) { + deck.sortSubdecksList(); + } + } + + debugLogToConsole(desc: string = null) { + let str: string = desc != null ? `${desc}: ` : ""; + console.log((str += this.toString())); + } + + toString(indent: number = 0): string { + let result: string = ""; + let indentStr: string = " ".repeat(indent * 4); + + result += `${indentStr}${this.deckName}\r\n`; + indentStr += " "; + for (let i = 0; i < this.newFlashcards.length; i++) { + const card = this.newFlashcards[i]; + result += `${indentStr}New: ${i}: ${card.front}::${card.back}\r\n`; + } + for (let i = 0; i < this.dueFlashcards.length; i++) { + const card = this.dueFlashcards[i]; + const s = card.isDue ? "Due" : "Not due"; + result += `${indentStr}${s}: ${i}: ${card.front}::${card.back}\r\n`; + } + + for (const subdeck of this.subdecks) { + result += subdeck.toString(indent + 1); + } + return result; + } + + clone(): Deck { + return this.copyWithCardFilter(() => true); + } + + copyWithCardFilter(predicate: (value: Card) => boolean, parent: Deck = null): Deck { + const result: Deck = new Deck(this.deckName, parent); + result.newFlashcards = [...this.newFlashcards.filter((card) => predicate(card))]; + result.dueFlashcards = [...this.dueFlashcards.filter((card) => predicate(card))]; + + for (const s of this.subdecks) { + const newParent = result; + const newDeck = s.copyWithCardFilter(predicate, newParent); + result.subdecks.push(newDeck); + } + return result; + } + + static otherListType(cardListType: CardListType): CardListType { + let result: CardListType; + if (cardListType == CardListType.NewCard) result = CardListType.DueCard; + else if (cardListType == CardListType.DueCard) result = CardListType.NewCard; + else throw "Invalid cardListType"; + return result; + } +} + +export class DeckTreeFilter { + static filterForReviewableCards(reviewableDeckTree: Deck): Deck { + return reviewableDeckTree.copyWithCardFilter((card) => !card.question.hasEditLaterTag); + } + + static filterForRemainingCards( + questionPostponementList: IQuestionPostponementList, + deckTree: Deck, + reviewMode: FlashcardReviewMode, + ): Deck { + return deckTree.copyWithCardFilter( + (card) => + (reviewMode == FlashcardReviewMode.Cram || card.isNew || card.isDue) && + !questionPostponementList.includes(card.question), + ); + } +} diff --git a/src/DeckTreeIterator.ts b/src/DeckTreeIterator.ts new file mode 100644 index 00000000..8f5b39d7 --- /dev/null +++ b/src/DeckTreeIterator.ts @@ -0,0 +1,231 @@ +import { Card } from "./Card"; +import { CardListType, Deck } from "./Deck"; +import { Question } from "./Question"; +import { TopicPath } from "./TopicPath"; +import { globalRandomNumberProvider } from "./util/RandomNumberProvider"; + +export enum CardListOrder { + NewFirst, + DueFirst, + Random, +} +export enum OrderMethod { + Sequential, + Random, +} +export enum IteratorDeckSource { + UpdatedByIterator, + CloneBeforeUse, +} + +export interface IIteratorOrder { + // Choose decks in sequential order, or randomly + deckOrder: OrderMethod; + + // Within a deck, choose: new cards first, due cards first, or randomly + cardListOrder: CardListOrder; + + // Within a card list (i.e. either new or due), choose cards sequentially or randomly + cardOrder: OrderMethod; +} + +export interface IDeckTreeIterator { + get currentDeck(): Deck; + get currentCard(): Card; + get hasCurrentCard(): boolean; + setDeck(deck: Deck): void; + deleteCurrentCard(): boolean; + deleteCurrentQuestion(): boolean; + moveCurrentCardToEndOfList(): void; + nextCard(): boolean; +} + +class SingleDeckIterator { + deck: Deck; + iteratorOrder: IIteratorOrder; + preferredCardListType: CardListType; + cardIdx?: number; + cardListType?: CardListType; + + get hasCurrentCard(): boolean { + return this.cardIdx != null; + } + + get currentCard(): Card { + if (this.cardIdx == null) return null; + return this.deck.getCard(this.cardIdx, this.cardListType); + } + + constructor(iteratorOrder: IIteratorOrder) { + this.iteratorOrder = iteratorOrder; + this.preferredCardListType = + this.iteratorOrder.cardListOrder == CardListOrder.DueFirst + ? CardListType.DueCard + : CardListType.NewCard; + } + + setDeck(deck: Deck): void { + this.deck = deck; + this.setCardListType(null); + } + + private setCardListType(cardListType?: CardListType): void { + this.cardListType = cardListType; + this.cardIdx = null; + } + + nextCard(): boolean { + // First return cards in the preferred list + if (this.cardListType == null) { + this.setCardListType(this.preferredCardListType); + } + + if (!this.nextCardWithinList()) { + if (this.cardListType == this.preferredCardListType) { + // Nothing left in the preferred list, so try the non-preferred list type + this.setCardListType(Deck.otherListType(this.cardListType)); + if (!this.nextCardWithinList()) { + this.setCardListType(null); + } + } else { + this.cardIdx = null; + } + } + return this.cardIdx != null; + } + + private nextCardWithinList(): boolean { + let result: boolean = false; + const cardList: Card[] = this.deck.getCardListForCardType(this.cardListType); + + // Delete the current card so we don't return it again + if (this.hasCurrentCard) { + this.deleteCurrentCard(); + } + result = cardList.length > 0; + if (result) { + switch (this.iteratorOrder.cardOrder) { + case OrderMethod.Sequential: + this.cardIdx = 0; + break; + + case OrderMethod.Random: + this.cardIdx = globalRandomNumberProvider.getInteger(0, cardList.length - 1); + break; + } + } + return result; + } + + deleteCurrentQuestion(): void { + this.ensureCurrentCard(); + const q: Question = this.currentCard.question; + const cards: Card[] = this.deck.getCardListForCardType(this.cardListType); + do { + this.deck.deleteCardAtIndex(this.cardIdx, this.cardListType); + } while (this.cardIdx < cards.length && Object.is(q, cards[this.cardIdx].question)); + this.setNoCurrentCard(); + } + + 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.setNoCurrentCard(); + } + + private setNoCurrentCard() { + this.cardIdx = null; + } + + private ensureCurrentCard() { + if (this.cardIdx == null || this.cardListType == null) throw "no current card"; + } +} + +export class DeckTreeIterator implements IDeckTreeIterator { + deckTree: Deck; + preferredCardListType: CardListType; + iteratorOrder: IIteratorOrder; + deckSource: IteratorDeckSource; + + singleDeckIterator: SingleDeckIterator; + deckArray: Deck[]; + deckIdx?: number; + + get hasCurrentCard(): boolean { + return this.deckIdx != null && this.singleDeckIterator.hasCurrentCard; + } + + get currentDeck(): Deck { + if (this.deckIdx == null) return null; + return this.deckArray[this.deckIdx]; + } + + get currentCard(): Card { + if (this.deckIdx == null || !this.singleDeckIterator.hasCurrentCard) return null; + return this.singleDeckIterator.currentCard; + } + + constructor(iteratorOrder: IIteratorOrder, deckSource: IteratorDeckSource) { + this.singleDeckIterator = new SingleDeckIterator(iteratorOrder); + this.iteratorOrder = iteratorOrder; + this.deckSource = deckSource; + } + + setDeck(deck: Deck): void { + // We don't want to change the supplied deck, so first clone + if (this.deckSource == IteratorDeckSource.CloneBeforeUse) deck = deck.clone(); + + this.deckTree = deck; + this.deckArray = deck.toDeckArray(); + this.setDeckIdx(null); + } + + private setDeckIdx(deckIdx?: number): void { + this.deckIdx = deckIdx; + if (deckIdx != null) this.singleDeckIterator.setDeck(this.deckArray[deckIdx]); + } + + nextCard(): boolean { + let result: boolean = false; + if (this.deckIdx == null) { + this.setDeckIdx(0); + } + while (this.deckIdx < this.deckArray.length) { + if (this.singleDeckIterator.nextCard()) { + result = true; + break; + } + this.deckIdx++; + if (this.deckIdx < this.deckArray.length) { + this.singleDeckIterator.setDeck(this.deckArray[this.deckIdx]); + } + } + if (!result) this.deckIdx = null; + return result; + } + + deleteCurrentQuestion(): boolean { + this.singleDeckIterator.deleteCurrentQuestion(); + return this.nextCard(); + } + + deleteCurrentCard(): boolean { + this.singleDeckIterator.deleteCurrentCard(); + return this.nextCard(); + } + + moveCurrentCardToEndOfList(): void { + this.singleDeckIterator.moveCurrentCardToEndOfList(); + } +} diff --git a/src/DeckTreeStatsCalculator.ts b/src/DeckTreeStatsCalculator.ts new file mode 100644 index 00000000..e810b094 --- /dev/null +++ b/src/DeckTreeStatsCalculator.ts @@ -0,0 +1,41 @@ +import { Deck } from "./Deck"; +import { + CardListOrder, + DeckTreeIterator, + IDeckTreeIterator, + IIteratorOrder, + IteratorDeckSource, + OrderMethod, +} from "./DeckTreeIterator"; +import { Card } from "./Card"; +import { Stats } from "./stats"; +import { CardScheduleInfo } from "./CardSchedule"; + +export class DeckTreeStatsCalculator { + private deckTree: Deck; + + calculate(deckTree: Deck): Stats { + // Order doesn't matter as long as we iterate over everything + const iteratorOrder: IIteratorOrder = { + deckOrder: OrderMethod.Sequential, + cardListOrder: CardListOrder.DueFirst, + cardOrder: OrderMethod.Sequential, + }; + const iterator: IDeckTreeIterator = new DeckTreeIterator( + iteratorOrder, + IteratorDeckSource.CloneBeforeUse, + ); + const result = new Stats(); + iterator.setDeck(deckTree); + while (iterator.nextCard()) { + const card: Card = iterator.currentCard; + if (card.hasSchedule) { + const schedule: CardScheduleInfo = card.scheduleInfo; + result.update(schedule.delayBeforeReviewDaysInt, schedule.interval, schedule.ease); + } else { + result.incrementNew(); + } + } + return result; + } +} diff --git a/src/FlashcardReviewSequencer.ts b/src/FlashcardReviewSequencer.ts new file mode 100644 index 00000000..af522bb1 --- /dev/null +++ b/src/FlashcardReviewSequencer.ts @@ -0,0 +1,188 @@ +import { Card } from "./Card"; +import { CardListType, Deck } from "./Deck"; +import { Question, QuestionText } from "./Question"; +import { ReviewResponse } from "./scheduling"; +import { SRSettings } from "./settings"; +import { TopicPath } from "./TopicPath"; +import { CardScheduleInfo, ICardScheduleCalculator } from "./CardSchedule"; +import { Note } from "./Note"; +import { IDeckTreeIterator } from "./DeckTreeIterator"; +import { IQuestionPostponementList } from "./QuestionPostponementList"; + +export interface IFlashcardReviewSequencer { + get hasCurrentCard(): boolean; + get currentCard(): Card; + get currentQuestion(): Question; + get currentNote(): Note; + get currentDeck(): Deck; + get originalDeckTree(): Deck; + + setDeckTree(originalDeckTree: Deck, remainingDeckTree: Deck): void; + setCurrentDeck(topicPath: TopicPath): void; + getDeckStats(topicPath: TopicPath): DeckStats; + skipCurrentCard(): void; + determineCardSchedule(response: ReviewResponse, card: Card): CardScheduleInfo; + processReview(response: ReviewResponse): Promise; + updateCurrentQuestionText(text: string): Promise; +} + +export class DeckStats { + dueCount: number; + newCount: number; + totalCount: number; + + constructor(dueCount: number, newCount: number, totalCount: number) { + this.dueCount = dueCount; + this.newCount = newCount; + this.totalCount = totalCount; + } +} + +export enum FlashcardReviewMode { + Cram, + Review, +} + +export class FlashcardReviewSequencer implements IFlashcardReviewSequencer { + private _originalDeckTree: Deck; + private remainingDeckTree: Deck; + private reviewMode: FlashcardReviewMode; + private cardSequencer: IDeckTreeIterator; + private settings: SRSettings; + private cardScheduleCalculator: ICardScheduleCalculator; + private questionPostponementList: IQuestionPostponementList; + + constructor( + reviewMode: FlashcardReviewMode, + cardSequencer: IDeckTreeIterator, + settings: SRSettings, + cardScheduleCalculator: ICardScheduleCalculator, + questionPostponementList: IQuestionPostponementList, + ) { + this.reviewMode = reviewMode; + this.cardSequencer = cardSequencer; + this.settings = settings; + this.cardScheduleCalculator = cardScheduleCalculator; + this.questionPostponementList = questionPostponementList; + } + + get hasCurrentCard(): boolean { + return this.cardSequencer.currentCard != null; + } + + get currentCard(): Card { + return this.cardSequencer.currentCard; + } + + get currentQuestion(): Question { + return this.currentCard?.question; + } + + get currentDeck(): Deck { + return this.cardSequencer.currentDeck; + } + + get currentNote(): Note { + return this.currentQuestion.note; + } + + setDeckTree(originalDeckTree: Deck, remainingDeckTree: Deck): void { + 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.nextCard(); + } + + get originalDeckTree(): Deck { + return this._originalDeckTree; + } + + getDeckStats(topicPath: TopicPath): DeckStats { + const totalCount: number = this._originalDeckTree + .getDeck(topicPath) + .getCardCount(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); + return new DeckStats(dueCount, newCount, totalCount); + } + + skipCurrentCard(): void { + this.questionPostponementList.addIfRequired(this.currentQuestion); + this.cardSequencer.deleteCurrentQuestion(); + } + + private deleteCurrentCard(): void { + this.cardSequencer.deleteCurrentCard(); + } + + async processReview(response: ReviewResponse): Promise { + switch (this.reviewMode) { + case FlashcardReviewMode.Review: + await this.processReview_ReviewMode(response); + break; + + case FlashcardReviewMode.Cram: + await this.processReview_CramMode(response); + break; + } + } + + async processReview_ReviewMode(response: ReviewResponse): Promise { + this.currentCard.scheduleInfo = this.determineCardSchedule(response, this.currentCard); + + // Update the source file with the updated schedule + await this.currentQuestion.writeQuestion(this.settings); + + // Move/delete the card + if (response == ReviewResponse.Reset) { + this.cardSequencer.moveCurrentCardToEndOfList(); + this.cardSequencer.nextCard(); + } else this.deleteCurrentCard(); + } + + async processReview_CramMode(response: ReviewResponse): Promise { + if (response == ReviewResponse.Easy) this.deleteCurrentCard(); + else { + this.cardSequencer.moveCurrentCardToEndOfList(); + this.cardSequencer.nextCard(); + } + } + + determineCardSchedule(response: ReviewResponse, card: Card): CardScheduleInfo { + let result: CardScheduleInfo; + + if (response == ReviewResponse.Reset) { + // Resetting the card schedule + result = this.cardScheduleCalculator.getResetCardSchedule(); + } else { + // scheduled card + if (card.hasSchedule) { + result = this.cardScheduleCalculator.calcUpdatedSchedule( + response, + card.scheduleInfo, + ); + } else { + const currentNote: Note = card.question.note; + result = this.cardScheduleCalculator.getNewCardSchedule( + response, + currentNote.filePath, + ); + } + } + return result; + } + + async updateCurrentQuestionText(text: string): Promise { + const q: QuestionText = this.currentQuestion.questionText; + + q.actualQuestion = text; + + await this.currentQuestion.writeQuestion(this.settings); + } +} diff --git a/src/Note.ts b/src/Note.ts new file mode 100644 index 00000000..46ae5cb8 --- /dev/null +++ b/src/Note.ts @@ -0,0 +1,51 @@ +import { SRSettings } from "./settings"; +import { Deck } from "./Deck"; +import { Question } from "./Question"; +import { ISRFile } from "./SRFile"; + +export class Note { + file: ISRFile; + questionList: Question[]; + + get hasChanged(): boolean { + return this.questionList.some((question) => question.hasChanged); + } + + get filePath(): string { + return this.file.path; + } + + constructor(file: ISRFile, questionList: Question[]) { + this.file = file; + this.questionList = questionList; + questionList.forEach((question) => (question.note = this)); + } + + appendCardsToDeck(deck: Deck): void { + for (const question of this.questionList) { + for (const card of question.cards) { + deck.appendCard(question.topicPath, card); + } + } + } + + debugLogToConsole(desc: string = "") { + 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`; + } + console.debug(str); + } + + async writeNoteFile(settings: SRSettings): Promise { + let fileText: string = await this.file.read(); + for (const question of this.questionList) { + if (question.hasChanged) { + fileText = question.updateQuestionText(fileText, settings); + } + } + await this.file.write(fileText); + this.questionList.forEach((question) => (question.hasChanged = false)); + } +} diff --git a/src/NoteEaseCalculator.ts b/src/NoteEaseCalculator.ts new file mode 100644 index 00000000..24cc1bee --- /dev/null +++ b/src/NoteEaseCalculator.ts @@ -0,0 +1,31 @@ +import { Note } from "./Note"; +import { SRSettings } from "./settings"; + +export class NoteEaseCalculator { + static Calculate(note: Note, settings: SRSettings): number { + let totalEase: number = 0; + let scheduledCount: number = 0; + + note.questionList.forEach((question) => { + question.cards + .filter((card) => card.hasSchedule) + .forEach((card) => { + totalEase += card.scheduleInfo.ease; + scheduledCount++; + }); + }); + + let result: number = 0; + if (scheduledCount > 0) { + const flashcardsInNoteAvgEase: number = totalEase / scheduledCount; + const flashcardContribution: number = Math.min( + 1.0, + Math.log(scheduledCount + 0.5) / Math.log(64), + ); + result = + flashcardsInNoteAvgEase * flashcardContribution + + settings.baseEase * (1.0 - flashcardContribution); + } + return result; + } +} diff --git a/src/NoteEaseList.ts b/src/NoteEaseList.ts new file mode 100644 index 00000000..d69d97bd --- /dev/null +++ b/src/NoteEaseList.ts @@ -0,0 +1,36 @@ +import { SRSettings } from "./settings"; + +export interface INoteEaseList { + hasEaseForPath(path: string): boolean; + getEaseByPath(path: string): number; + setEaseForPath(path: string, ease: number): void; +} + +export class NoteEaseList implements INoteEaseList { + settings: SRSettings; + dict: Record = {}; + + constructor(settings: SRSettings) { + this.settings = settings; + } + + get baseEase() { + return this.settings.baseEase; + } + + hasEaseForPath(path: string): boolean { + return Object.prototype.hasOwnProperty.call(this.dict, path); + } + + getEaseByPath(path: string): number { + let ease: number = this.baseEase; + if (this.hasEaseForPath(path)) { + ease = Math.round(this.dict[path]); + } + return ease; + } + + setEaseForPath(path: string, ease: number): void { + this.dict[path] = ease; + } +} diff --git a/src/NoteFileLoader.ts b/src/NoteFileLoader.ts new file mode 100644 index 00000000..83ed69e9 --- /dev/null +++ b/src/NoteFileLoader.ts @@ -0,0 +1,32 @@ +import { ISRFile } from "./SRFile"; +import { Note } from "./Note"; +import { Question } from "./Question"; +import { TopicPath } from "./TopicPath"; +import { NoteQuestionParser } from "./NoteQuestionParser"; +import { SRSettings } from "./settings"; + +export class NoteFileLoader { + fileText: string; + fixesMade: boolean; + noteTopicPath: TopicPath; + noteFile: ISRFile; + settings: SRSettings; + + constructor(settings: SRSettings) { + this.settings = settings; + } + + async load(noteFile: ISRFile, noteTopicPath: TopicPath): Promise { + this.noteFile = noteFile; + + const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings); + + const questionList: Question[] = await questionParser.createQuestionList( + noteFile, + noteTopicPath, + ); + + const result: Note = new Note(noteFile, questionList); + return result; + } +} diff --git a/src/NoteParser.ts b/src/NoteParser.ts new file mode 100644 index 00000000..01a00ab6 --- /dev/null +++ b/src/NoteParser.ts @@ -0,0 +1,22 @@ +import { NoteQuestionParser } from "./NoteQuestionParser"; +import { ISRFile } from "./SRFile"; +import { Note } from "./Note"; +import { SRSettings } from "./settings"; +import { TopicPath } from "./TopicPath"; + +export class NoteParser { + settings: SRSettings; + noteText: string; + + constructor(settings: SRSettings) { + this.settings = settings; + } + + async parse(noteFile: ISRFile, folderTopicPath: TopicPath): Promise { + const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings); + const questions = await questionParser.createQuestionList(noteFile, folderTopicPath); + + const result: Note = new Note(noteFile, questions); + return result; + } +} diff --git a/src/NoteQuestionParser.ts b/src/NoteQuestionParser.ts new file mode 100644 index 00000000..1f8c01b6 --- /dev/null +++ b/src/NoteQuestionParser.ts @@ -0,0 +1,147 @@ +import { Card } from "./Card"; +import { CardScheduleInfo, NoteCardScheduleParser } from "./CardSchedule"; +import { parse } from "./parser"; +import { CardType, Question } from "./Question"; +import { CardFrontBack, CardFrontBackUtil } from "./QuestionType"; +import { SRSettings } 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; + } +} + +export class NoteQuestionParser { + settings: SRSettings; + noteFile: ISRFile; + noteTopicPath: TopicPath; + noteText: string; + + constructor(settings: SRSettings) { + this.settings = settings; + } + + async createQuestionList(noteFile: ISRFile, folderTopicPath: TopicPath): Promise { + this.noteFile = noteFile; + const noteText: string = await noteFile.read(); + let noteTopicPath: TopicPath; + if (this.settings.convertFoldersToDecks) { + noteTopicPath = folderTopicPath; + } else { + const tagList: string[] = noteFile.getAllTags(); + noteTopicPath = this.determineTopicPathFromTags(tagList); + } + const result: Question[] = this.doCreateQuestionList(noteText, noteTopicPath); + return result; + } + + private doCreateQuestionList(noteText: string, noteTopicPath: TopicPath): Question[] { + this.noteText = noteText; + this.noteTopicPath = noteTopicPath; + + 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 question: Question = this.createQuestionObject(parsedQuestionInfo); + + // Each rawCardText can turn into multiple CardFrontBack's (e.g. CardType.Cloze, CardType.SingleLineReversed) + const cardFrontBackList: CardFrontBack[] = CardFrontBackUtil.expand( + question.questionType, + question.questionText.actualQuestion, + this.settings, + ); + + // And if the card has been reviewed, then scheduling info as well + let cardScheduleInfoList: CardScheduleInfo[] = + NoteCardScheduleParser.createCardScheduleInfoList(question.questionText.original); + + // we have some extra scheduling dates to delete + const correctLength = cardFrontBackList.length; + if (cardScheduleInfoList.length > correctLength) { + question.hasChanged = true; + cardScheduleInfoList = cardScheduleInfoList.slice(0, correctLength); + } + + // Create the list of card objects, and attach to the question + const cardList: Card[] = this.createCardList(cardFrontBackList, cardScheduleInfoList); + question.setCardList(cardList); + result.push(question); + } + return result; + } + + private parseQuestions(): [CardType, string, number][] { + const settings: SRSettings = this.settings; + const result: [CardType, string, number][] = parse( + this.noteText, + settings.singleLineCardSeparator, + settings.singleLineReversedCardSeparator, + settings.multilineCardSeparator, + settings.multilineReversedCardSeparator, + settings.convertHighlightsToClozes, + settings.convertBoldTextToClozes, + settings.convertCurlyBracketsToClozes, + ); + return result; + } + + private createQuestionObject(parsedQuestionInfo: ParsedQuestionInfo): Question { + const { cardType, cardText, lineNo } = parsedQuestionInfo; + + const questionContext: string[] = this.noteFile.getQuestionContext(lineNo); + const result = Question.Create( + this.settings, + cardType, + this.noteTopicPath, + cardText, + lineNo, + questionContext, + ); + return result; + } + + private createCardList( + cardFrontBackList: CardFrontBack[], + cardScheduleInfoList: CardScheduleInfo[], + ): Card[] { + const siblings: Card[] = []; + + // One card for each CardFrontBack, regardless if there is scheduled info for it + for (let i = 0; i < cardFrontBackList.length; i++) { + const { front, back } = cardFrontBackList[i]; + + const hasScheduleInfo: boolean = i < cardScheduleInfoList.length; + const cardObj: Card = new Card({ + front, + back, + cardIdx: i, + }); + cardObj.scheduleInfo = hasScheduleInfo ? cardScheduleInfoList[i] : null; + + siblings.push(cardObj); + } + 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; + } + } + } + return result; + } +} diff --git a/src/Question.ts b/src/Question.ts new file mode 100644 index 00000000..82948c10 --- /dev/null +++ b/src/Question.ts @@ -0,0 +1,223 @@ +import { Card } from "./Card"; +import { CardScheduleInfo, NoteCardScheduleParser } from "./CardSchedule"; +import { SR_HTML_COMMENT_BEGIN, SR_HTML_COMMENT_END } from "./constants"; +import { Note } from "./Note"; +import { SRSettings } from "./settings"; +import { TopicPath } from "./TopicPath"; +import { MultiLineTextFinder } from "./util/MultiLineTextFinder"; +import { cyrb53 } from "./util/utils"; + +export enum CardType { + SingleLineBasic, + SingleLineReversed, + MultiLineBasic, + MultiLineReversed, + Cloze, +} + +// +// QuestionText comprises the following components: +// 1. QuestionTopicPath (optional) +// 2. Actual question text (mandatory) +// 3. Card schedule info as HTML comment (optional) +// +// For example +// +// Actual question text only: +// Q1::A1 +// +// Question text with topic path: +// #flashcards/science Q2::A2 +// +// Question text with card schedule info: +// #flashcards/science Q2::A2 +// +export class QuestionText { + // Complete text including all components, as read from file + original: string; + + // The question topic path (only present if topic path included in original text) + topicPath: TopicPath; + + // The white space after the topic path, before the actualQuestion + // We keep this so that when a question is updated, we can retain the original spacing + postTopicPathWhiteSpace: string; + + // Just the question text, e.g. "Q1::A1" + actualQuestion: string; + + // Hash of string (topicPath + actualQuestion) + // Explicitly excludes the HTML comment with the scheduling info + textHash: string; + + constructor( + original: string, + topicPath: TopicPath, + postTopicPathWhiteSpace: string, + actualQuestion: string, + ) { + this.original = original; + this.topicPath = topicPath; + this.postTopicPathWhiteSpace = postTopicPathWhiteSpace; + this.actualQuestion = actualQuestion; + this.textHash = cyrb53(this.formatForNote()); + } + + endsWithCodeBlock(): boolean { + return this.actualQuestion.endsWith("```"); + } + + static create(original: string, settings: SRSettings): QuestionText { + const [topicPath, postTopicPathWhiteSpace, actualQuestion] = this.splitText( + original, + settings, + ); + + return new QuestionText(original, topicPath, postTopicPathWhiteSpace, actualQuestion); + } + + static splitText(original: string, settings: SRSettings): [TopicPath, string, string] { + const strippedSR = NoteCardScheduleParser.removeCardScheduleInfo(original).trim(); + let actualQuestion: string = strippedSR; + let whiteSpace: string = ""; + + let topicPath: TopicPath = TopicPath.emptyPath; + if (!settings.convertFoldersToDecks) { + const t = TopicPath.getTopicPathFromCardText(strippedSR); + if (t?.hasPath) { + topicPath = t; + [actualQuestion, whiteSpace] = + TopicPath.removeTopicPathFromStartOfCardText(strippedSR); + } + } + + return [topicPath, whiteSpace, actualQuestion]; + } + + formatForNote(): string { + let result: string = ""; + if (this.topicPath.hasPath) { + result += this.topicPath.formatAsTag(); + result += this.postTopicPathWhiteSpace ?? " "; + } + result += this.actualQuestion; + return result; + } +} + +export class Question { + note: Note; + questionType: CardType; + topicPath: TopicPath; + questionText: QuestionText; + lineNo: number; + hasEditLaterTag: boolean; + questionContext: string[]; + cards: Card[]; + hasChanged: boolean; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + getHtmlCommentSeparator(settings: SRSettings): string { + let sep: string = settings.cardCommentOnSameLine ? " " : "\n"; + // Override separator if last block is a codeblock + if (this.questionText.endsWithCodeBlock() && sep !== "\n") { + sep = "\n"; + } + return sep; + } + + setCardList(cards: Card[]): void { + this.cards = cards; + this.cards.forEach((card) => (card.question = this)); + } + + formatScheduleAsHtmlComment(settings: SRSettings): string { + let result: string = SR_HTML_COMMENT_BEGIN; + + // We always want the correct schedule format, so we use this if there is no schedule for a card + + for (let i = 0; i < this.cards.length; i++) { + const card: Card = this.cards[i]; + const schedule: CardScheduleInfo = card.hasSchedule + ? card.scheduleInfo + : CardScheduleInfo.getDummySchedule(settings); + result += schedule.formatSchedule(); + } + result += SR_HTML_COMMENT_END; + return result; + } + + formatForNote(settings: SRSettings): string { + let result: string = this.questionText.formatForNote(); + if (this.cards.some((card) => card.hasSchedule)) { + result += + this.getHtmlCommentSeparator(settings) + this.formatScheduleAsHtmlComment(settings); + } + return result; + } + + updateQuestionText(noteText: string, settings: SRSettings): string { + const originalText: string = this.questionText.original; + + // Get the entire text for the question including: + // 1. the topic path (if present), + // 2. the question text + // 3. the schedule HTML comment (if present) + const replacementText = this.formatForNote(settings); + + let newText = MultiLineTextFinder.findAndReplace(noteText, originalText, replacementText); + if (newText) { + this.questionText = QuestionText.create(replacementText, settings); + } else { + console.error( + `updateQuestionText: Text not found: ${originalText.substring( + 0, + 100, + )} in note: ${noteText.substring(0, 100)}`, + ); + newText = noteText; + } + return newText; + } + + async writeQuestion(settings: SRSettings): Promise { + const fileText: string = await this.note.file.read(); + + const newText: string = this.updateQuestionText(fileText, settings); + await this.note.file.write(newText); + this.hasChanged = false; + } + + static Create( + settings: SRSettings, + questionType: CardType, + noteTopicPath: TopicPath, + originalText: string, + lineNo: number, + context: string[], + ): Question { + const hasEditLaterTag = originalText.includes(settings.editLaterTag); + const questionText: QuestionText = QuestionText.create(originalText, settings); + + let topicPath: TopicPath = noteTopicPath; + if (questionText.topicPath.hasPath) { + topicPath = questionText.topicPath; + } + + const result: Question = new Question({ + questionType, + topicPath, + questionText, + lineNo, + hasEditLaterTag, + questionContext: context, + cards: null, + hasChanged: false, + }); + + return result; + } +} diff --git a/src/QuestionPostponementList.ts b/src/QuestionPostponementList.ts new file mode 100644 index 00000000..07bcd19d --- /dev/null +++ b/src/QuestionPostponementList.ts @@ -0,0 +1,38 @@ +import { Question } from "./Question"; +import SRPlugin from "./main"; +import { SRSettings } from "./settings"; + +export interface IQuestionPostponementList { + clear(): void; + addIfRequired(question: Question): void; + includes(question: Question): boolean; + write(): Promise; +} + +export class QuestionPostponementList implements IQuestionPostponementList { + list: string[]; + plugin: SRPlugin; + settings: SRSettings; + + constructor(plugin: SRPlugin, settings: SRSettings, list: string[]) { + this.plugin = plugin; + this.settings = settings; + this.list = list; + } + + clear(): void { + this.list = []; + } + + addIfRequired(question: Question): void { + if (this.settings.burySiblingCards) this.list.push(question.questionText.textHash); + } + + includes(question: Question): boolean { + return this.list.includes(question.questionText.textHash); + } + + async write(): Promise { + await this.plugin.savePluginData(); + } +} diff --git a/src/QuestionType.ts b/src/QuestionType.ts new file mode 100644 index 00000000..2592d07b --- /dev/null +++ b/src/QuestionType.ts @@ -0,0 +1,171 @@ +import { CardType } from "./Question"; +import { SRSettings } from "./settings"; + +export class CardFrontBack { + front: string; + back: string; + + constructor(front: string, back: string) { + this.front = front.trim(); + this.back = back.trim(); + } +} + +export class CardFrontBackUtil { + static expand( + questionType: CardType, + questionText: string, + settings: SRSettings, + ): CardFrontBack[] { + const handler: IQuestionTypeHandler = QuestionTypeFactory.create(questionType); + return handler.expand(questionText, settings); + } +} + +export interface IQuestionTypeHandler { + expand(questionText: string, settings: SRSettings): CardFrontBack[]; +} + +class QuestionType_SingleLineBasic implements IQuestionTypeHandler { + expand(questionText: string, settings: SRSettings): CardFrontBack[] { + const idx: number = questionText.indexOf(settings.singleLineCardSeparator); + const item: CardFrontBack = new CardFrontBack( + questionText.substring(0, idx), + questionText.substring(idx + settings.singleLineCardSeparator.length), + ); + const result: CardFrontBack[] = [item]; + return result; + } +} + +class QuestionType_SingleLineReversed implements IQuestionTypeHandler { + expand(questionText: string, settings: SRSettings): CardFrontBack[] { + const idx: number = questionText.indexOf(settings.singleLineReversedCardSeparator); + const side1: string = questionText.substring(0, idx), + side2: string = questionText.substring( + idx + settings.singleLineReversedCardSeparator.length, + ); + const result: CardFrontBack[] = [ + new CardFrontBack(side1, side2), + new CardFrontBack(side2, side1), + ]; + return result; + } +} + +class QuestionType_MultiLineBasic implements IQuestionTypeHandler { + expand(questionText: string, settings: SRSettings): CardFrontBack[] { + const idx = questionText.indexOf("\n" + settings.multilineCardSeparator + "\n"); + const item: CardFrontBack = new CardFrontBack( + questionText.substring(0, idx), + questionText.substring(idx + 2 + settings.multilineCardSeparator.length), + ); + const result: CardFrontBack[] = [item]; + return result; + } +} + +class QuestionType_MultiLineReversed implements IQuestionTypeHandler { + expand(questionText: string, settings: SRSettings): CardFrontBack[] { + const idx = questionText.indexOf("\n" + settings.multilineReversedCardSeparator + "\n"); + const side1: string = questionText.substring(0, idx), + side2: string = questionText.substring( + idx + 2 + settings.multilineReversedCardSeparator.length, + ); + + const result: CardFrontBack[] = [ + new CardFrontBack(side1, side2), + new CardFrontBack(side2, side1), + ]; + return result; + } +} + +class QuestionType_Cloze implements IQuestionTypeHandler { + expand(questionText: string, settings: SRSettings): CardFrontBack[] { + const siblings: RegExpMatchArray[] = []; + if (settings.convertHighlightsToClozes) { + siblings.push(...questionText.matchAll(/==(.*?)==/gm)); + } + if (settings.convertBoldTextToClozes) { + siblings.push(...questionText.matchAll(/\*\*(.*?)\*\*/gm)); + } + if (settings.convertCurlyBracketsToClozes) { + siblings.push(...questionText.matchAll(/{{(.*?)}}/gm)); + } + siblings.sort((a, b) => { + if (a.index < b.index) { + return -1; + } + if (a.index > b.index) { + return 1; + } + // What is unit test to cover following statement; otherwise jest please ignore + return 0; + }); + + let front: string, back: string; + const result: CardFrontBack[] = []; + for (const m of siblings) { + const deletionStart: number = m.index, + deletionEnd: number = deletionStart + m[0].length; + front = + questionText.substring(0, deletionStart) + + QuestionType_ClozeUtil.renderClozeFront() + + questionText.substring(deletionEnd); + front = front + .replace(/==/gm, "") + .replace(/\*\*/gm, "") + .replace(/{{/gm, "") + .replace(/}}/gm, ""); + back = + questionText.substring(0, deletionStart) + + QuestionType_ClozeUtil.renderClozeBack( + questionText.substring(deletionStart, deletionEnd), + ) + + questionText.substring(deletionEnd); + back = back + .replace(/==/gm, "") + .replace(/\*\*/gm, "") + .replace(/{{/gm, "") + .replace(/}}/gm, ""); + result.push(new CardFrontBack(front, back)); + } + + return result; + } +} + +export class QuestionType_ClozeUtil { + static renderClozeFront(): string { + return "[...]"; + } + + static renderClozeBack(str: string): string { + return "" + str + ""; + } +} + +export class QuestionTypeFactory { + static create(questionType: CardType): IQuestionTypeHandler { + let handler: IQuestionTypeHandler; + switch (questionType) { + case CardType.SingleLineBasic: + handler = new QuestionType_SingleLineBasic(); + break; + case CardType.SingleLineReversed: + handler = new QuestionType_SingleLineReversed(); + break; + case CardType.MultiLineBasic: + handler = new QuestionType_MultiLineBasic(); + break; + case CardType.MultiLineReversed: + handler = new QuestionType_MultiLineReversed(); + break; + case CardType.Cloze: + handler = new QuestionType_Cloze(); + break; + } + return handler; + } +} diff --git a/src/review-deck.ts b/src/ReviewDeck.ts similarity index 100% rename from src/review-deck.ts rename to src/ReviewDeck.ts diff --git a/src/SRFile.ts b/src/SRFile.ts new file mode 100644 index 00000000..46378070 --- /dev/null +++ b/src/SRFile.ts @@ -0,0 +1,109 @@ +import { + MetadataCache, + TFile, + Vault, + getAllTags as ObsidianGetAllTags, + HeadingCache, +} from "obsidian"; +import { getAllTagsFromText } from "./util/utils"; + +export interface ISRFile { + get path(): string; + get basename(): string; + getAllTags(): string[]; + getQuestionContext(cardLine: number): string[]; + read(): Promise; + write(content: string): Promise; +} + +export class SrTFile implements ISRFile { + file: TFile; + vault: Vault; + metadataCache: MetadataCache; + + constructor(vault: Vault, metadataCache: MetadataCache, file: TFile) { + this.vault = vault; + this.metadataCache = metadataCache; + this.file = file; + } + + get path(): string { + return this.file.path; + } + + get basename(): string { + return this.file.basename; + } + + getAllTags(): string[] { + const fileCachedData = this.metadataCache.getFileCache(this.file) || {}; + return ObsidianGetAllTags(fileCachedData) || []; + } + + getQuestionContext(cardLine: number): string[] { + const fileCachedData = this.metadataCache.getFileCache(this.file) || {}; + const headings: HeadingCache[] = fileCachedData.headings || []; + const stack: HeadingCache[] = []; + for (const heading of headings) { + if (heading.position.start.line > cardLine) { + break; + } + + while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) { + stack.pop(); + } + + stack.push(heading); + } + + const result = []; + for (const headingObj of stack) { + headingObj.heading = headingObj.heading.replace(/\[\^\d+\]/gm, "").trim(); + result.push(headingObj.heading); + } + return result; + } + + async read(): Promise { + return await this.vault.read(this.file); + } + + async write(content: string): Promise { + 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 new file mode 100644 index 00000000..23914066 --- /dev/null +++ b/src/TopicPath.ts @@ -0,0 +1,120 @@ +import { SRSettings } from "src/settings"; +import { OBSIDIAN_TAG_AT_STARTOFLINE_REGEX } from "./constants"; +import { ISRFile } from "./SRFile"; + +export class TopicPath { + path: string[]; + + constructor(path: string[]) { + if (path == null) throw "null path"; + if (path.some((str) => str.includes("/"))) throw "path entries must not contain '/'"; + this.path = path; + } + + get hasPath(): boolean { + return this.path.length > 0; + } + + get isEmptyPath(): boolean { + return !this.hasPath; + } + + static get emptyPath(): TopicPath { + return new TopicPath([]); + } + + shift(): string { + if (this.isEmptyPath) throw "can't shift an empty path"; + return this.path.shift(); + } + + clone(): TopicPath { + return new TopicPath([...this.path]); + } + + formatAsTag(): string { + if (this.isEmptyPath) throw "Empty path"; + const result = "#" + this.path.join("/"); + 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; + for (let i = 0; i < this.path.length; i++) { + if (this.path[i] != topicPath.path[i]) return false; + } + return true; + } + + static getTopicPathFromCardText(cardText: string): TopicPath { + const path = cardText.trimStart().match(OBSIDIAN_TAG_AT_STARTOFLINE_REGEX)?.slice(-1)[0]; + return path?.length > 0 ? TopicPath.getTopicPathFromTag(path) : null; + } + + static removeTopicPathFromStartOfCardText(cardText: string): [string, string] { + const cardText1: string = cardText + .trimStart() + .replaceAll(OBSIDIAN_TAG_AT_STARTOFLINE_REGEX, ""); + const cardText2: string = cardText1.trimStart(); + const whiteSpaceLength: number = cardText1.length - cardText2.length; + const whiteSpace: string = cardText1.substring(0, whiteSpaceLength); + return [cardText2, whiteSpace]; + } + + 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; + if (tag.length == 1) return false; + + return true; + } + + static getTopicPathFromTag(tag: string): TopicPath { + if (tag == null || tag.length == 0) throw "Null/empty tag"; + if (tag[0] != "#") throw "Tag must start with #"; + if (tag.length == 1) throw "Invalid tag"; + + const path: string[] = tag + .replace("#", "") + .split("/") + .filter((str) => str); + return new TopicPath(path); + } +} diff --git a/src/constants.ts b/src/constants.ts index d97f6084..7563cb53 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,9 @@ export const YAML_FRONT_MATTER_REGEX = /^---\n((?:.*\n)*?)---/; export const MULTI_SCHEDULING_EXTRACTOR = /!([\d-]+),(\d+),(\d+)/gm; export const LEGACY_SCHEDULING_EXTRACTOR = //gm; +export const OBSIDIAN_TAG_AT_STARTOFLINE_REGEX = /^#[^\s#]+/gi; +export const PREFERRED_DATE_FORMAT = "YYYY-MM-DD"; +export const ALLOWED_DATE_FORMATS = [PREFERRED_DATE_FORMAT, "DD-MM-YYYY", "ddd MMM DD YYYY"]; export const IMAGE_FORMATS = [ "jpg", @@ -24,3 +27,8 @@ export const VIDEO_FORMATS = ["mp4", "mkv", "avi", "mov"]; export const COLLAPSE_ICON = ''; + +export const TICKS_PER_DAY = 24 * 3600 * 1000; + +export const SR_HTML_COMMENT_BEGIN = ""; diff --git a/src/flashcard-modal.tsx b/src/flashcard-modal.tsx deleted file mode 100644 index 63db2a03..00000000 --- a/src/flashcard-modal.tsx +++ /dev/null @@ -1,1040 +0,0 @@ -import { - ButtonComponent, - Modal, - App, - MarkdownRenderer, - Notice, - Platform, - TFile, - TextAreaComponent, - setIcon, -} from "obsidian"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import h from "vhtml"; - -import type SRPlugin from "src/main"; -import { Card, CardType, schedule, textInterval, ReviewResponse } from "src/scheduling"; -import { - COLLAPSE_ICON, - MULTI_SCHEDULING_EXTRACTOR, - LEGACY_SCHEDULING_EXTRACTOR, - IMAGE_FORMATS, - AUDIO_FORMATS, - VIDEO_FORMATS, -} from "src/constants"; -import { escapeRegexString, cyrb53 } from "src/utils"; -import { t } from "src/lang/helpers"; - -export enum FlashcardModalMode { - DecksList, - Front, - Back, - Closed, -} - -// from https://github.com/chhoumann/quickadd/blob/bce0b4cdac44b867854d6233796e3406dfd163c6/src/gui/GenericInputPrompt/GenericInputPrompt.ts#L5 -export class FlashcardEditModal extends Modal { - public plugin: SRPlugin; - public input: string; - public waitForClose: Promise; - - private resolvePromise: (input: string) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private rejectPromise: (reason?: any) => void; - private didSubmit = false; - private inputComponent: TextAreaComponent; - private readonly modalText: string; - - public static Prompt(app: App, plugin: SRPlugin, placeholder: string): Promise { - const newPromptModal = new FlashcardEditModal(app, plugin, placeholder); - return newPromptModal.waitForClose; - } - constructor(app: App, plugin: SRPlugin, existingText: string) { - super(app); - this.plugin = plugin; - this.titleEl.setText(t("EDIT_CARD")); - this.titleEl.addClass("sr-centered"); - this.modalText = existingText; - - this.waitForClose = new Promise((resolve, reject) => { - this.resolvePromise = resolve; - this.rejectPromise = reject; - }); - this.display(); - this.open(); - } - - private display() { - this.contentEl.empty(); - this.modalEl.addClass("sr-flashcard-input-modal"); - - const mainContentContainer: HTMLDivElement = this.contentEl.createDiv(); - mainContentContainer.addClass("sr-flashcard-input-area"); - this.inputComponent = this.createInputField(mainContentContainer, this.modalText); - this.createButtonBar(mainContentContainer); - } - - private createButton( - container: HTMLElement, - text: string, - callback: (evt: MouseEvent) => void, - ) { - const btn = new ButtonComponent(container); - btn.setButtonText(text).onClick(callback); - return btn; - } - - private createButtonBar(mainContentContainer: HTMLDivElement) { - const buttonBarContainer: HTMLDivElement = mainContentContainer.createDiv(); - buttonBarContainer.addClass("sr-flashcard-edit-button-bar"); - this.createButton( - buttonBarContainer, - t("SAVE"), - this.submitClickCallback, - ).setCta().buttonEl.style.marginRight = "0"; - this.createButton(buttonBarContainer, t("CANCEL"), this.cancelClickCallback); - } - - protected createInputField(container: HTMLElement, value: string) { - const textComponent = new TextAreaComponent(container); - - textComponent.inputEl.style.width = "100%"; - textComponent - .setValue(value ?? "") - .onChange((value) => (this.input = value)) - .inputEl.addEventListener("keydown", this.submitEnterCallback); - - return textComponent; - } - - private submitClickCallback = (_: MouseEvent) => this.submit(); - private cancelClickCallback = (_: MouseEvent) => this.cancel(); - - private submitEnterCallback = (evt: KeyboardEvent) => { - if ((evt.ctrlKey || evt.metaKey) && evt.key === "Enter") { - evt.preventDefault(); - this.submit(); - } - }; - - private submit() { - this.didSubmit = true; - - this.close(); - } - - private cancel() { - this.close(); - } - - onOpen() { - super.onOpen(); - - this.inputComponent.inputEl.focus(); - } - - onClose() { - super.onClose(); - this.resolveInput(); - this.removeInputListener(); - } - - private resolveInput() { - if (!this.didSubmit) this.rejectPromise(t("NO_INPUT")); - else this.resolvePromise(this.input); - } - - private removeInputListener() { - this.inputComponent.inputEl.removeEventListener("keydown", this.submitEnterCallback); - } -} - -export class FlashcardModal extends Modal { - public plugin: SRPlugin; - public answerBtn: HTMLElement; - public flashcardView: HTMLElement; - public hardBtn: HTMLElement; - public goodBtn: HTMLElement; - public easyBtn: HTMLElement; - public nextBtn: HTMLElement; - public responseDiv: HTMLElement; - public resetButton: HTMLElement; - public editButton: HTMLElement; - public contextView: HTMLElement; - public currentCard: Card; - public currentCardIdx: number; - public currentDeck: Deck; - public checkDeck: Deck; - public mode: FlashcardModalMode; - public ignoreStats: boolean; - - constructor(app: App, plugin: SRPlugin, ignoreStats = false) { - super(app); - - this.plugin = plugin; - this.ignoreStats = ignoreStats; - - this.titleEl.setText(t("DECKS")); - this.titleEl.addClass("sr-centered"); - - if (Platform.isMobile) { - this.contentEl.style.display = "block"; - } - this.modalEl.style.height = this.plugin.data.settings.flashcardHeightPercentage + "%"; - this.modalEl.style.width = this.plugin.data.settings.flashcardWidthPercentage + "%"; - - this.contentEl.style.position = "relative"; - this.contentEl.style.height = "92%"; - this.contentEl.addClass("sr-modal-content"); - - // TODO: refactor into event handler? - document.body.onkeydown = (e) => { - // TODO: Please fix this. It's ugly. - // Checks if the input textbox is in focus before processing keyboard shortcuts. - if ( - document.activeElement.nodeName !== "TEXTAREA" && - this.mode !== FlashcardModalMode.DecksList - ) { - const consume = () => { - e.preventDefault(); - e.stopPropagation(); - }; - if (this.mode !== FlashcardModalMode.Closed && e.code === "KeyS") { - this.skipCurrentCard(); - consume(); - } else if ( - this.mode === FlashcardModalMode.Front && - (e.code === "Space" || e.code === "Enter") - ) { - this.showAnswer(); - consume(); - } else if (this.mode === FlashcardModalMode.Back) { - if (e.code === "Numpad1" || e.code === "Digit1") { - this.processReview(ReviewResponse.Hard); - consume(); - } else if (e.code === "Numpad2" || e.code === "Digit2" || e.code === "Space") { - this.processReview(ReviewResponse.Good); - consume(); - } else if (e.code === "Numpad3" || e.code === "Digit3") { - this.processReview(ReviewResponse.Easy); - consume(); - } else if (e.code === "Numpad0" || e.code === "Digit0") { - this.processReview(ReviewResponse.Reset); - consume(); - } - } - } - }; - } - - onOpen(): void { - this.decksList(); - } - - onClose(): void { - this.mode = FlashcardModalMode.Closed; - } - - decksList(): void { - const aimDeck = this.plugin.deckTree.subdecks.filter( - (deck) => deck.deckName === this.plugin.data.historyDeck, - ); - if (this.plugin.data.historyDeck && aimDeck.length > 0) { - const deck = aimDeck[0]; - this.currentDeck = deck; - this.checkDeck = deck.parent; - this.setupCardsView(); - deck.nextCard(this); - return; - } - - this.mode = FlashcardModalMode.DecksList; - this.titleEl.setText(t("DECKS")); - this.titleEl.innerHTML += ( -

- - {this.plugin.deckTree.dueFlashcardsCount.toString()} - - - {this.plugin.deckTree.newFlashcardsCount.toString()} - - - {this.plugin.deckTree.totalFlashcards.toString()} - -

- ); - this.contentEl.empty(); - this.contentEl.setAttribute("id", "sr-flashcard-view"); - - for (const deck of this.plugin.deckTree.subdecks) { - deck.render(this.contentEl, this); - } - } - - setupCardsView(): void { - this.contentEl.empty(); - - const flashCardMenu = this.contentEl.createDiv("sr-flashcard-menu"); - - const backButton = flashCardMenu.createEl("button"); - backButton.addClass("sr-flashcard-menu-item"); - setIcon(backButton, "arrow-left"); - backButton.setAttribute("aria-label", t("BACK")); - backButton.addEventListener("click", () => { - this.plugin.data.historyDeck = ""; - this.decksList(); - }); - - this.editButton = flashCardMenu.createEl("button"); - this.editButton.addClass("sr-flashcard-menu-item"); - setIcon(this.editButton, "edit"); - this.editButton.setAttribute("aria-label", t("EDIT_CARD")); - this.editButton.addEventListener("click", async () => { - // remove SR info from input modal prompt - const textPromptArr = this.currentCard.cardText.split("\n"); - let textPrompt = ""; - if (textPromptArr[textPromptArr.length - 1].startsWith("`; - } else { - let scheduling: (RegExpMatchArray | string[])[] = [ - ...this.currentCard.cardText.matchAll(MULTI_SCHEDULING_EXTRACTOR), - ]; - if (scheduling.length === 0) { - scheduling = [...this.currentCard.cardText.matchAll(LEGACY_SCHEDULING_EXTRACTOR)]; - } - - const currCardSched: string[] = ["0", dueString, interval.toString(), ease.toString()]; - if (this.currentCard.isDue) { - scheduling[this.currentCard.siblingIdx] = currCardSched; - } else { - scheduling.push(currCardSched); - } - - this.currentCard.cardText = this.currentCard.cardText.replace(//gm, ""); - this.currentCard.cardText += ""; - } - - fileText = fileText.replace(replacementRegex, () => this.currentCard.cardText); - for (const sibling of this.currentCard.siblings) { - sibling.cardText = this.currentCard.cardText; - } - if (this.plugin.data.settings.burySiblingCards) { - this.burySiblingCards(true); - } - - await this.app.vault.modify(this.currentCard.note, fileText); - this.currentDeck.nextCard(this); - } - - private async burySiblingCards(tillNextDay: boolean): Promise { - if (tillNextDay) { - this.plugin.data.buryList.push(cyrb53(this.currentCard.cardText)); - await this.plugin.savePluginData(); - } - - for (const sibling of this.currentCard.siblings) { - const dueIdx = this.currentDeck.dueFlashcards.indexOf(sibling); - const newIdx = this.currentDeck.newFlashcards.indexOf(sibling); - - if (dueIdx !== -1) { - this.currentDeck.deleteFlashcardAtIndex( - dueIdx, - this.currentDeck.dueFlashcards[dueIdx].isDue, - ); - } else if (newIdx !== -1) { - this.currentDeck.deleteFlashcardAtIndex( - newIdx, - this.currentDeck.newFlashcards[newIdx].isDue, - ); - } - } - } - - private skipCurrentCard(): void { - this.currentDeck.deleteFlashcardAtIndex(this.currentCardIdx, this.currentCard.isDue); - this.burySiblingCards(false); - this.currentDeck.nextCard(this); - } - - // slightly modified version of the renderMarkdown function in - // https://github.com/mgmeyers/obsidian-kanban/blob/main/src/KanbanView.tsx - async renderMarkdownWrapper( - markdownString: string, - containerEl: HTMLElement, - recursiveDepth = 0, - ): Promise { - if (recursiveDepth > 4) return; - - MarkdownRenderer.renderMarkdown( - markdownString, - containerEl, - this.currentCard.note.path, - this.plugin, - ); - - containerEl.findAll(".internal-embed").forEach((el) => { - const link = this.parseLink(el.getAttribute("src")); - - // file does not exist, display dead link - if (!link.target) { - el.innerText = link.text; - } else if (link.target instanceof TFile) { - if (link.target.extension !== "md") { - this.embedMediaFile(el, link.target); - } else { - el.innerText = ""; - this.renderTransclude(el, link, recursiveDepth); - } - } - }); - } - - private parseLink(src: string) { - const linkComponentsRegex = - /^(?[^#^]+)?(?:#(?!\^)(?.+)|#\^(?.+)|#)?$/; - const matched = typeof src === "string" && src.match(linkComponentsRegex); - const file = matched.groups.file || this.currentCard.note.path; - const target = this.plugin.app.metadataCache.getFirstLinkpathDest( - file, - this.currentCard.note.path, - ); - return { - text: matched[0], - file: matched.groups.file, - heading: matched.groups.heading, - blockId: matched.groups.blockId, - target: target, - }; - } - - private embedMediaFile(el: HTMLElement, target: TFile) { - el.innerText = ""; - if (IMAGE_FORMATS.includes(target.extension)) { - el.createEl( - "img", - { - attr: { - src: this.plugin.app.vault.getResourcePath(target), - }, - }, - (img) => { - if (el.hasAttribute("width")) - img.setAttribute("width", el.getAttribute("width")); - else img.setAttribute("width", "100%"); - if (el.hasAttribute("alt")) img.setAttribute("alt", el.getAttribute("alt")); - el.addEventListener( - "click", - (ev) => - ((ev.target as HTMLElement).style.minWidth = - (ev.target as HTMLElement).style.minWidth === "100%" - ? null - : "100%"), - ); - }, - ); - el.addClasses(["image-embed", "is-loaded"]); - } else if ( - AUDIO_FORMATS.includes(target.extension) || - VIDEO_FORMATS.includes(target.extension) - ) { - el.createEl( - AUDIO_FORMATS.includes(target.extension) ? "audio" : "video", - { - attr: { - controls: "", - src: this.plugin.app.vault.getResourcePath(target), - }, - }, - (audio) => { - if (el.hasAttribute("alt")) audio.setAttribute("alt", el.getAttribute("alt")); - }, - ); - el.addClasses(["media-embed", "is-loaded"]); - } else { - el.innerText = target.path; - } - } - - private async renderTransclude( - el: HTMLElement, - link: { - text: string; - file: string; - heading: string; - blockId: string; - target: TFile; - }, - recursiveDepth: number, - ) { - const cache = this.app.metadataCache.getCache(link.target.path); - const text = await this.app.vault.cachedRead(link.target); - let blockText; - if (link.heading) { - const clean = (s: string) => s.replace(/[\W\s]/g, ""); - const headingIndex = cache.headings?.findIndex( - (h) => clean(h.heading) === clean(link.heading), - ); - const heading = cache.headings[headingIndex]; - - const startAt = heading.position.start.offset; - const endAt = - cache.headings.slice(headingIndex + 1).find((h) => h.level <= heading.level) - ?.position?.start?.offset || text.length; - - blockText = text.substring(startAt, endAt); - } else if (link.blockId) { - const block = cache.blocks[link.blockId]; - const startAt = block.position.start.offset; - const endAt = block.position.end.offset; - blockText = text.substring(startAt, endAt); - } else { - blockText = text; - } - - this.renderMarkdownWrapper(blockText, el, recursiveDepth + 1); - } -} - -export class Deck { - public deckName: string; - public newFlashcards: Card[]; - public newFlashcardsCount = 0; // counts those in subdecks too - public dueFlashcards: Card[]; - public dueFlashcardsCount = 0; // counts those in subdecks too - public totalFlashcards = 0; // counts those in subdecks too - public subdecks: Deck[]; - public parent: Deck | null; - - constructor(deckName: string, parent: Deck | null) { - this.deckName = deckName; - this.newFlashcards = []; - this.newFlashcardsCount = 0; - this.dueFlashcards = []; - this.dueFlashcardsCount = 0; - this.totalFlashcards = 0; - this.subdecks = []; - this.parent = parent; - } - - createDeck(deckPath: string[]): void { - if (deckPath.length === 0) { - return; - } - - const deckName: string = deckPath.shift(); - for (const deck of this.subdecks) { - if (deckName === deck.deckName) { - deck.createDeck(deckPath); - return; - } - } - - const deck: Deck = new Deck(deckName, this); - this.subdecks.push(deck); - deck.createDeck(deckPath); - } - - insertFlashcard(deckPath: string[], cardObj: Card): void { - if (cardObj.isDue) { - this.dueFlashcardsCount++; - } else { - this.newFlashcardsCount++; - } - this.totalFlashcards++; - - if (deckPath.length === 0) { - if (cardObj.isDue) { - this.dueFlashcards.push(cardObj); - } else { - this.newFlashcards.push(cardObj); - } - return; - } - - const deckName: string = deckPath.shift(); - for (const deck of this.subdecks) { - if (deckName === deck.deckName) { - deck.insertFlashcard(deckPath, cardObj); - return; - } - } - } - - // count flashcards that have either been buried - // or aren't due yet - countFlashcard(deckPath: string[], n = 1): void { - this.totalFlashcards += n; - - const deckName: string = deckPath.shift(); - for (const deck of this.subdecks) { - if (deckName === deck.deckName) { - deck.countFlashcard(deckPath, n); - return; - } - } - } - - deleteFlashcardAtIndex(index: number, cardIsDue: boolean): void { - if (cardIsDue) { - this.dueFlashcards.splice(index, 1); - this.dueFlashcardsCount--; - } else { - this.newFlashcards.splice(index, 1); - this.newFlashcardsCount--; - } - - let deck: Deck = this.parent; - while (deck !== null) { - if (cardIsDue) { - deck.dueFlashcardsCount--; - } else { - deck.newFlashcardsCount--; - } - deck = deck.parent; - } - } - - sortSubdecksList(): void { - this.subdecks.sort((a, b) => { - if (a.deckName < b.deckName) { - return -1; - } else if (a.deckName > b.deckName) { - return 1; - } - return 0; - }); - - for (const deck of this.subdecks) { - deck.sortSubdecksList(); - } - } - - render(containerEl: HTMLElement, modal: FlashcardModal): void { - const deckView: HTMLElement = containerEl.createDiv("tree-item"); - - const deckViewSelf: HTMLElement = deckView.createDiv( - "tree-item-self tag-pane-tag is-clickable", - ); - const shouldBeInitiallyExpanded: boolean = - modal.plugin.data.settings.initiallyExpandAllSubdecksInTree; - let collapsed = !shouldBeInitiallyExpanded; - let collapseIconEl: HTMLElement | null = null; - if (this.subdecks.length > 0) { - collapseIconEl = deckViewSelf.createDiv("tree-item-icon collapse-icon"); - collapseIconEl.innerHTML = COLLAPSE_ICON; - (collapseIconEl.childNodes[0] as HTMLElement).style.transform = collapsed - ? "rotate(-90deg)" - : ""; - } - - const deckViewInner: HTMLElement = deckViewSelf.createDiv("tree-item-inner"); - deckViewInner.addEventListener("click", () => { - modal.plugin.data.historyDeck = this.deckName; - modal.currentDeck = this; - modal.checkDeck = this.parent; - modal.setupCardsView(); - this.nextCard(modal); - }); - const deckViewInnerText: HTMLElement = deckViewInner.createDiv("tag-pane-tag-text"); - deckViewInnerText.innerHTML += {this.deckName}; - const deckViewOuter: HTMLElement = deckViewSelf.createDiv("tree-item-flair-outer"); - deckViewOuter.innerHTML += ( - - - {this.dueFlashcardsCount.toString()} - - - {this.newFlashcardsCount.toString()} - - - {this.totalFlashcards.toString()} - - - ); - - const deckViewChildren: HTMLElement = deckView.createDiv("tree-item-children"); - deckViewChildren.style.display = collapsed ? "none" : "block"; - if (this.subdecks.length > 0) { - collapseIconEl.addEventListener("click", () => { - if (collapsed) { - (collapseIconEl.childNodes[0] as HTMLElement).style.transform = ""; - deckViewChildren.style.display = "block"; - } else { - (collapseIconEl.childNodes[0] as HTMLElement).style.transform = - "rotate(-90deg)"; - deckViewChildren.style.display = "none"; - } - collapsed = !collapsed; - }); - } - for (const deck of this.subdecks) { - deck.render(deckViewChildren, modal); - } - } - - nextCard(modal: FlashcardModal): void { - if (this.newFlashcards.length + this.dueFlashcards.length === 0) { - if (this.dueFlashcardsCount + this.newFlashcardsCount > 0) { - for (const deck of this.subdecks) { - if (deck.dueFlashcardsCount + deck.newFlashcardsCount > 0) { - modal.currentDeck = deck; - deck.nextCard(modal); - return; - } - } - } - - if (this.parent == modal.checkDeck) { - modal.plugin.data.historyDeck = ""; - modal.decksList(); - } else { - this.parent.nextCard(modal); - } - return; - } - - modal.responseDiv.style.display = "none"; - modal.resetButton.disabled = true; - modal.titleEl.setText( - `${this.deckName}: ${this.dueFlashcardsCount + this.newFlashcardsCount}`, - ); - - modal.answerBtn.style.display = "initial"; - modal.flashcardView.empty(); - modal.mode = FlashcardModalMode.Front; - - let interval = 1.0, - ease: number = modal.plugin.data.settings.baseEase, - delayBeforeReview = 0; - if (this.dueFlashcards.length > 0) { - if (modal.plugin.data.settings.randomizeCardOrder) { - modal.currentCardIdx = Math.floor(Math.random() * this.dueFlashcards.length); - } else { - modal.currentCardIdx = 0; - } - modal.currentCard = this.dueFlashcards[modal.currentCardIdx]; - modal.renderMarkdownWrapper(modal.currentCard.front, modal.flashcardView); - - interval = modal.currentCard.interval; - ease = modal.currentCard.ease; - delayBeforeReview = modal.currentCard.delayBeforeReview; - } else if (this.newFlashcards.length > 0) { - if (modal.plugin.data.settings.randomizeCardOrder) { - const pickedCardIdx = Math.floor(Math.random() * this.newFlashcards.length); - modal.currentCardIdx = pickedCardIdx; - - // look for first unscheduled sibling - const pickedCard: Card = this.newFlashcards[pickedCardIdx]; - let idx = pickedCardIdx; - while (idx >= 0 && pickedCard.siblings.includes(this.newFlashcards[idx])) { - if (!this.newFlashcards[idx].isDue) { - modal.currentCardIdx = idx; - } - idx--; - } - } else { - modal.currentCardIdx = 0; - } - - modal.currentCard = this.newFlashcards[modal.currentCardIdx]; - modal.renderMarkdownWrapper(modal.currentCard.front, modal.flashcardView); - - if ( - Object.prototype.hasOwnProperty.call( - modal.plugin.easeByPath, - modal.currentCard.note.path, - ) - ) { - ease = modal.plugin.easeByPath[modal.currentCard.note.path]; - } - } - - const hardInterval: number = schedule( - ReviewResponse.Hard, - interval, - ease, - delayBeforeReview, - modal.plugin.data.settings, - ).interval; - const goodInterval: number = schedule( - ReviewResponse.Good, - interval, - ease, - delayBeforeReview, - modal.plugin.data.settings, - ).interval; - const easyInterval: number = schedule( - ReviewResponse.Easy, - interval, - ease, - delayBeforeReview, - modal.plugin.data.settings, - ).interval; - - if (modal.ignoreStats) { - // Same for mobile/desktop - modal.hardBtn.setText(`${modal.plugin.data.settings.flashcardHardText}`); - modal.easyBtn.setText(`${modal.plugin.data.settings.flashcardEasyText}`); - } else if (Platform.isMobile) { - modal.hardBtn.setText(textInterval(hardInterval, true)); - modal.goodBtn.setText(textInterval(goodInterval, true)); - modal.easyBtn.setText(textInterval(easyInterval, true)); - } else { - modal.hardBtn.setText( - `${modal.plugin.data.settings.flashcardHardText} - ${textInterval( - hardInterval, - false, - )}`, - ); - modal.goodBtn.setText( - `${modal.plugin.data.settings.flashcardGoodText} - ${textInterval( - goodInterval, - false, - )}`, - ); - modal.easyBtn.setText( - `${modal.plugin.data.settings.flashcardEasyText} - ${textInterval( - easyInterval, - false, - )}`, - ); - } - - if (modal.plugin.data.settings.showContextInCards) - modal.contextView.setText(modal.currentCard.context); - } -} diff --git a/src/gui/flashcard-modal.tsx b/src/gui/flashcard-modal.tsx new file mode 100644 index 00000000..a574de4d --- /dev/null +++ b/src/gui/flashcard-modal.tsx @@ -0,0 +1,515 @@ +import { Modal, App, Notice, Platform, setIcon } from "obsidian"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import h from "vhtml"; + +import type SRPlugin from "src/main"; +import { SRSettings } from "src/settings"; +import { textInterval, ReviewResponse } from "src/scheduling"; +import { COLLAPSE_ICON } from "src/constants"; +import { t } from "src/lang/helpers"; +import { Card } from "../Card"; +import { CardListType, Deck } from "../Deck"; +import { CardType, Question } from "../Question"; +import { + DeckStats, + FlashcardReviewMode, + IFlashcardReviewSequencer as IFlashcardReviewSequencer, +} from "src/FlashcardReviewSequencer"; +import { FlashcardEditModal } from "./flashcards-edit-modal"; +import { Note } from "src/Note"; +import { RenderMarkdownWrapper } from "src/util/RenderMarkdownWrapper"; +import { CardScheduleInfo } from "src/CardSchedule"; +import { TopicPath } from "src/TopicPath"; + +export enum FlashcardModalMode { + DecksList, + Front, + Back, + Closed, +} + +export class FlashcardModal extends Modal { + public plugin: SRPlugin; + public answerBtn: HTMLElement; + public flashcardView: HTMLElement; + private flashCardMenu: HTMLDivElement; + public hardBtn: HTMLElement; + public goodBtn: HTMLElement; + public easyBtn: HTMLElement; + public nextBtn: HTMLElement; + public responseDiv: HTMLElement; + public resetButton: HTMLButtonElement; + public editButton: HTMLElement; + public contextView: HTMLElement; + public mode: FlashcardModalMode; + private reviewSequencer: IFlashcardReviewSequencer; + private settings: SRSettings; + private reviewMode: FlashcardReviewMode; + + private get currentCard(): Card { + return this.reviewSequencer.currentCard; + } + + private get currentQuestion(): Question { + return this.reviewSequencer.currentQuestion; + } + + private get currentNote(): Note { + return this.reviewSequencer.currentNote; + } + + constructor( + app: App, + plugin: SRPlugin, + settings: SRSettings, + reviewSequencer: IFlashcardReviewSequencer, + reviewMode: FlashcardReviewMode, + ) { + super(app); + + this.plugin = plugin; + this.settings = settings; + this.reviewSequencer = reviewSequencer; + this.reviewMode = reviewMode; + + this.titleEl.setText(t("DECKS")); + this.titleEl.addClass("sr-centered"); + + if (Platform.isMobile) { + this.contentEl.style.display = "block"; + } + this.modalEl.style.height = this.settings.flashcardHeightPercentage + "%"; + this.modalEl.style.width = this.settings.flashcardWidthPercentage + "%"; + + this.contentEl.style.position = "relative"; + this.contentEl.style.height = "92%"; + this.contentEl.addClass("sr-modal-content"); + + // TODO: refactor into event handler? + document.body.onkeydown = (e) => { + // TODO: Please fix this. It's ugly. + // Checks if the input textbox is in focus before processing keyboard shortcuts. + if ( + document.activeElement.nodeName !== "TEXTAREA" && + this.mode !== FlashcardModalMode.DecksList + ) { + const consume = () => { + e.preventDefault(); + e.stopPropagation(); + }; + if (this.mode !== FlashcardModalMode.Closed && e.code === "KeyS") { + this.skipCurrentCard(); + consume(); + } else if ( + this.mode === FlashcardModalMode.Front && + (e.code === "Space" || e.code === "Enter" || e.code === "NumpadEnter") + ) { + this.showAnswer(); + consume(); + } else if (this.mode === FlashcardModalMode.Back) { + if (e.code === "Numpad1" || e.code === "Digit1") { + this.processReview(ReviewResponse.Hard); + consume(); + } else if (e.code === "Numpad2" || e.code === "Digit2" || e.code === "Space") { + this.processReview(ReviewResponse.Good); + consume(); + } else if (e.code === "Numpad3" || e.code === "Digit3") { + this.processReview(ReviewResponse.Easy); + consume(); + } else if (e.code === "Numpad0" || e.code === "Digit0") { + this.processReview(ReviewResponse.Reset); + consume(); + } + } + } + }; + } + + onOpen(): void { + this.renderDecksList(); + } + + onClose(): void { + this.mode = FlashcardModalMode.Closed; + } + + renderDecksList(): void { + this.mode = FlashcardModalMode.DecksList; + const stats: DeckStats = this.reviewSequencer.getDeckStats(TopicPath.emptyPath); + this.titleEl.setText(t("DECKS")); + this.titleEl.innerHTML += ( +

+ + {stats.dueCount.toString()} + + + {stats.newCount.toString()} + + + {stats.totalCount.toString()} + +

+ ); + this.contentEl.empty(); + this.contentEl.setAttribute("id", "sr-flashcard-view"); + + for (const deck of this.reviewSequencer.originalDeckTree.subdecks) { + this.renderDeck(deck, this.contentEl, this); + } + } + + renderDeck(deck: Deck, containerEl: HTMLElement, modal: FlashcardModal): void { + const deckView: HTMLElement = containerEl.createDiv("tree-item"); + + const deckViewSelf: HTMLElement = deckView.createDiv( + "tree-item-self tag-pane-tag is-clickable", + ); + const shouldBeInitiallyExpanded: boolean = modal.settings.initiallyExpandAllSubdecksInTree; + let collapsed = !shouldBeInitiallyExpanded; + let collapseIconEl: HTMLElement | null = null; + if (deck.subdecks.length > 0) { + collapseIconEl = deckViewSelf.createDiv("tree-item-icon collapse-icon"); + collapseIconEl.innerHTML = COLLAPSE_ICON; + (collapseIconEl.childNodes[0] as HTMLElement).style.transform = collapsed + ? "rotate(-90deg)" + : ""; + } + + const deckViewInner: HTMLElement = deckViewSelf.createDiv("tree-item-inner"); + const deckViewInnerText: HTMLElement = deckViewInner.createDiv("tag-pane-tag-text"); + deckViewInnerText.innerHTML += {deck.deckName}; + const deckViewOuter: HTMLElement = deckViewSelf.createDiv("tree-item-flair-outer"); + const deckStats = this.reviewSequencer.getDeckStats(deck.getTopicPath()); + deckViewOuter.innerHTML += ( + + + {deckStats.dueCount.toString()} + + + {deckStats.newCount.toString()} + + + {deckStats.totalCount.toString()} + + + ); + + const deckViewChildren: HTMLElement = deckView.createDiv("tree-item-children"); + deckViewChildren.style.display = collapsed ? "none" : "block"; + if (deck.subdecks.length > 0) { + collapseIconEl.addEventListener("click", (e) => { + if (collapsed) { + (collapseIconEl.childNodes[0] as HTMLElement).style.transform = ""; + deckViewChildren.style.display = "block"; + } else { + (collapseIconEl.childNodes[0] as HTMLElement).style.transform = + "rotate(-90deg)"; + deckViewChildren.style.display = "none"; + } + + // We stop the propagation of the event so that the click event for deckViewSelf doesn't get called + // if the user clicks on the collapse icon + e.stopPropagation(); + collapsed = !collapsed; + }); + } + + // Add the click handler to deckViewSelf instead of deckViewInner so that it activates + // over the entire rectangle of the tree item, not just the text of the topic name + // https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/709 + deckViewSelf.addEventListener("click", () => { + this.startReviewOfDeck(deck); + }); + + for (const subdeck of deck.subdecks) { + this.renderDeck(subdeck, deckViewChildren, modal); + } + } + + startReviewOfDeck(deck: Deck) { + this.reviewSequencer.setCurrentDeck(deck.getTopicPath()); + if (this.reviewSequencer.hasCurrentCard) { + this.setupCardsView(); + this.showCurrentCard(); + } else this.renderDecksList(); + } + + setupCardsView(): void { + this.contentEl.empty(); + + this.flashCardMenu = this.contentEl.createDiv("sr-flashcard-menu"); + + this.createBackButton(); + this.createEditButton(); + this.createResetButton(); + this.createCardInfoButton(); + this.createSkipButton(); + + if (this.settings.showContextInCards) { + this.contextView = this.contentEl.createDiv(); + this.contextView.setAttribute("id", "sr-context"); + } + + this.flashcardView = this.contentEl.createDiv("div"); + this.flashcardView.setAttribute("id", "sr-flashcard-view"); + + this.createResponseButtons(); + + this.createShowAnswerButton(); + + if (this.reviewMode == FlashcardReviewMode.Cram) { + this.goodBtn.style.display = "none"; + + this.responseDiv.addClass("sr-ignorestats-response"); + this.easyBtn.addClass("sr-ignorestats-btn"); + this.hardBtn.addClass("sr-ignorestats-btn"); + } + } + + createShowAnswerButton() { + this.answerBtn = this.contentEl.createDiv(); + this.answerBtn.setAttribute("id", "sr-show-answer"); + this.answerBtn.setText(t("SHOW_ANSWER")); + this.answerBtn.addEventListener("click", () => { + this.showAnswer(); + }); + } + + createResponseButtons() { + this.responseDiv = this.contentEl.createDiv("sr-flashcard-response"); + + this.hardBtn = document.createElement("button"); + this.hardBtn.setAttribute("id", "sr-hard-btn"); + this.hardBtn.setText(this.settings.flashcardHardText); + this.hardBtn.addEventListener("click", () => { + this.processReview(ReviewResponse.Hard); + }); + this.responseDiv.appendChild(this.hardBtn); + + this.goodBtn = document.createElement("button"); + this.goodBtn.setAttribute("id", "sr-good-btn"); + this.goodBtn.setText(this.settings.flashcardGoodText); + this.goodBtn.addEventListener("click", () => { + this.processReview(ReviewResponse.Good); + }); + this.responseDiv.appendChild(this.goodBtn); + + this.easyBtn = document.createElement("button"); + this.easyBtn.setAttribute("id", "sr-easy-btn"); + this.easyBtn.setText(this.settings.flashcardEasyText); + this.easyBtn.addEventListener("click", () => { + this.processReview(ReviewResponse.Easy); + }); + this.responseDiv.appendChild(this.easyBtn); + this.responseDiv.style.display = "none"; + } + + createSkipButton() { + const skipButton = this.flashCardMenu.createEl("button"); + skipButton.addClass("sr-flashcard-menu-item"); + setIcon(skipButton, "chevrons-right"); + skipButton.setAttribute("aria-label", t("SKIP")); + skipButton.addEventListener("click", () => { + this.skipCurrentCard(); + }); + } + + createCardInfoButton() { + const cardInfo = this.flashCardMenu.createEl("button"); + cardInfo.addClass("sr-flashcard-menu-item"); + setIcon(cardInfo, "info"); + cardInfo.setAttribute("aria-label", "View Card Info"); + cardInfo.addEventListener("click", async () => { + this.displayCurrentCardInfoNotice(); + }); + } + + displayCurrentCardInfoNotice() { + const schedule = this.currentCard.scheduleInfo; + const currentEaseStr = t("CURRENT_EASE_HELP_TEXT") + (schedule?.ease ?? t("NEW")); + const currentIntervalStr = + t("CURRENT_INTERVAL_HELP_TEXT") + textInterval(schedule?.interval, false); + const generatedFromStr = t("CARD_GENERATED_FROM", { + notePath: this.currentQuestion.note.filePath, + }); + new Notice(currentEaseStr + "\n" + currentIntervalStr + "\n" + generatedFromStr); + } + + createBackButton() { + const backButton = this.flashCardMenu.createEl("button"); + backButton.addClass("sr-flashcard-menu-item"); + setIcon(backButton, "arrow-left"); + backButton.setAttribute("aria-label", t("BACK")); + backButton.addEventListener("click", () => { + /* this.plugin.data.historyDeck = ""; */ + this.renderDecksList(); + }); + } + + createResetButton() { + this.resetButton = this.flashCardMenu.createEl("button"); + this.resetButton.addClass("sr-flashcard-menu-item"); + setIcon(this.resetButton, "refresh-cw"); + this.resetButton.setAttribute("aria-label", t("RESET_CARD_PROGRESS")); + this.resetButton.addEventListener("click", () => { + this.processReview(ReviewResponse.Reset); + }); + } + + createEditButton() { + this.editButton = this.flashCardMenu.createEl("button"); + this.editButton.addClass("sr-flashcard-menu-item"); + setIcon(this.editButton, "edit"); + this.editButton.setAttribute("aria-label", t("EDIT_CARD")); + this.editButton.addEventListener("click", async () => { + this.doEditQuestionText(); + }); + } + + async doEditQuestionText(): Promise { + const currentQ: Question = this.reviewSequencer.currentQuestion; + + // Just the question/answer text; without any preceding topic tag + const textPrompt = currentQ.questionText.actualQuestion; + + const editModal = FlashcardEditModal.Prompt(this.app, textPrompt); + editModal + .then(async (modifiedCardText) => { + this.reviewSequencer.updateCurrentQuestionText(modifiedCardText); + }) + .catch((reason) => console.log(reason)); + } + + private showAnswer(): void { + this.mode = FlashcardModalMode.Back; + + this.answerBtn.style.display = "none"; + this.responseDiv.style.display = "grid"; + + if (this.currentCard.hasSchedule) { + this.resetButton.disabled = false; + } + + if (this.currentQuestion.questionType !== CardType.Cloze) { + const hr: HTMLElement = document.createElement("hr"); + hr.setAttribute("id", "sr-hr-card-divide"); + this.flashcardView.appendChild(hr); + } else { + this.flashcardView.empty(); + } + + const wrapper: RenderMarkdownWrapper = new RenderMarkdownWrapper( + this.app, + this.plugin, + this.currentNote.filePath, + ); + wrapper.renderMarkdownWrapper(this.currentCard.back, this.flashcardView); + } + + private async processReview(response: ReviewResponse): Promise { + await this.reviewSequencer.processReview(response); + // console.log(`processReview: ${response}: ${this.currentCard?.front ?? 'None'}`) + await this.handleNextCard(); + } + + private async skipCurrentCard(): Promise { + this.reviewSequencer.skipCurrentCard(); + // console.log(`skipCurrentCard: ${this.currentCard?.front ?? 'None'}`) + await this.handleNextCard(); + } + + private async handleNextCard(): Promise { + if (this.currentCard != null) await this.showCurrentCard(); + else this.renderDecksList(); + } + + private async showCurrentCard(): Promise { + const deck: Deck = this.reviewSequencer.currentDeck; + + this.responseDiv.style.display = "none"; + this.resetButton.disabled = true; + this.titleEl.setText(`${deck.deckName}: ${deck.getCardCount(CardListType.All, true)}`); + + this.answerBtn.style.display = "initial"; + this.flashcardView.empty(); + this.mode = FlashcardModalMode.Front; + + const wrapper: RenderMarkdownWrapper = new RenderMarkdownWrapper( + this.app, + this.plugin, + this.currentNote.filePath, + ); + await wrapper.renderMarkdownWrapper(this.currentCard.front, this.flashcardView); + + if (this.reviewMode == FlashcardReviewMode.Cram) { + // Same for mobile/desktop + this.hardBtn.setText(`${this.settings.flashcardHardText}`); + this.easyBtn.setText(`${this.settings.flashcardEasyText}`); + } else { + this.setupEaseButton( + this.hardBtn, + this.settings.flashcardHardText, + ReviewResponse.Hard, + ); + this.setupEaseButton( + this.goodBtn, + this.settings.flashcardGoodText, + ReviewResponse.Good, + ); + this.setupEaseButton( + this.easyBtn, + this.settings.flashcardEasyText, + ReviewResponse.Easy, + ); + } + + if (this.settings.showContextInCards) + this.contextView.setText( + this.formatQuestionContextText(this.currentQuestion.questionContext), + ); + } + + private formatQuestionContextText(questionContext: string[]): string { + const result = `${this.currentNote.file.basename} > ${questionContext.join(" > ")}`; + return result; + } + + private setupEaseButton( + button: HTMLElement, + buttonName: string, + reviewResponse: ReviewResponse, + ) { + const schedule: CardScheduleInfo = this.reviewSequencer.determineCardSchedule( + reviewResponse, + this.currentCard, + ); + const interval: number = schedule.interval; + + if (Platform.isMobile) { + button.setText(textInterval(interval, true)); + } else { + button.setText(`${buttonName} - ${textInterval(interval, false)}`); + } + } +} diff --git a/src/gui/flashcards-edit-modal.ts b/src/gui/flashcards-edit-modal.ts new file mode 100644 index 00000000..1330c811 --- /dev/null +++ b/src/gui/flashcards-edit-modal.ts @@ -0,0 +1,117 @@ +import { App, ButtonComponent, Modal, TextAreaComponent } from "obsidian"; +import { t } from "src/lang/helpers"; + +// from https://github.com/chhoumann/quickadd/blob/bce0b4cdac44b867854d6233796e3406dfd163c6/src/gui/GenericInputPrompt/GenericInputPrompt.ts#L5 +export class FlashcardEditModal extends Modal { + public input: string; + public waitForClose: Promise; + + private resolvePromise: (input: string) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private rejectPromise: (reason?: any) => void; + private didSubmit = false; + private inputComponent: TextAreaComponent; + private readonly modalText: string; + + public static Prompt(app: App, placeholder: string): Promise { + const newPromptModal = new FlashcardEditModal(app, placeholder); + return newPromptModal.waitForClose; + } + constructor(app: App, existingText: string) { + super(app); + this.titleEl.setText(t("EDIT_CARD")); + this.titleEl.addClass("sr-centered"); + this.modalText = existingText; + + this.waitForClose = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + }); + this.display(); + this.open(); + } + + private display() { + this.contentEl.empty(); + this.modalEl.addClass("sr-flashcard-input-modal"); + + const mainContentContainer: HTMLDivElement = this.contentEl.createDiv(); + mainContentContainer.addClass("sr-flashcard-input-area"); + this.inputComponent = this.createInputField(mainContentContainer, this.modalText); + this.createButtonBar(mainContentContainer); + } + + private createButton( + container: HTMLElement, + text: string, + callback: (evt: MouseEvent) => void, + ) { + const btn = new ButtonComponent(container); + btn.setButtonText(text).onClick(callback); + return btn; + } + + private createButtonBar(mainContentContainer: HTMLDivElement) { + const buttonBarContainer: HTMLDivElement = mainContentContainer.createDiv(); + buttonBarContainer.addClass("sr-flashcard-edit-button-bar"); + this.createButton( + buttonBarContainer, + t("SAVE"), + this.submitClickCallback, + ).setCta().buttonEl.style.marginRight = "0"; + this.createButton(buttonBarContainer, t("CANCEL"), this.cancelClickCallback); + } + + protected createInputField(container: HTMLElement, value: string) { + const textComponent = new TextAreaComponent(container); + + textComponent.inputEl.style.width = "100%"; + textComponent + .setValue(value ?? "") + .onChange((value) => (this.input = value)) + .inputEl.addEventListener("keydown", this.submitEnterCallback); + + return textComponent; + } + + private submitClickCallback = (_: MouseEvent) => this.submit(); + private cancelClickCallback = (_: MouseEvent) => this.cancel(); + + private submitEnterCallback = (evt: KeyboardEvent) => { + if ((evt.ctrlKey || evt.metaKey) && evt.key === "Enter") { + evt.preventDefault(); + this.submit(); + } + }; + + private submit() { + this.didSubmit = true; + + this.close(); + } + + private cancel() { + this.close(); + } + + onOpen() { + super.onOpen(); + + this.inputComponent.inputEl.focus(); + } + + onClose() { + super.onClose(); + this.resolveInput(); + this.removeInputListener(); + } + + private resolveInput() { + if (!this.didSubmit) this.rejectPromise(t("NO_INPUT")); + else this.resolvePromise(this.input); + } + + private removeInputListener() { + this.inputComponent.inputEl.removeEventListener("keydown", this.submitEnterCallback); + } +} diff --git a/src/sidebar.ts b/src/gui/sidebar.ts similarity index 99% rename from src/sidebar.ts rename to src/gui/sidebar.ts index 57913c77..be88025a 100644 --- a/src/sidebar.ts +++ b/src/gui/sidebar.ts @@ -2,7 +2,7 @@ import { ItemView, WorkspaceLeaf, Menu, TFile } from "obsidian"; import type SRPlugin from "src/main"; import { COLLAPSE_ICON } from "src/constants"; -import { ReviewDeck } from "src/review-deck"; +import { ReviewDeck } from "src/ReviewDeck"; import { t } from "src/lang/helpers"; export const REVIEW_QUEUE_VIEW_TYPE = "review-queue-list-view"; diff --git a/src/stats-modal.tsx b/src/gui/stats-modal.tsx similarity index 83% rename from src/stats-modal.tsx rename to src/gui/stats-modal.tsx index b635a26c..e04cb870 100644 --- a/src/stats-modal.tsx +++ b/src/gui/stats-modal.tsx @@ -17,9 +17,11 @@ import { } from "chart.js"; import type SRPlugin from "src/main"; -import { getKeysPreserveType, getTypedObjectEntries } from "src/utils"; +import { getKeysPreserveType, getTypedObjectEntries } from "src/util/utils"; import { textInterval } from "src/scheduling"; import { t } from "src/lang/helpers"; +import { Stats } from "../stats"; +import { CardListType } from "src/Deck"; Chart.register( BarElement, @@ -34,14 +36,6 @@ Chart.register( ArcElement, ); -export interface Stats { - eases: Record; - intervals: Record; - newCount: number; - youngCount: number; - matureCount: number; -} - export class StatsModal extends Modal { private plugin: SRPlugin; @@ -76,15 +70,14 @@ export class StatsModal extends Modal { contentEl.style.textAlign = "center"; // Add forecast - let maxN: number = Math.max(...getKeysPreserveType(this.plugin.dueDatesFlashcards)); + const cardStats: Stats = this.plugin.cardStats; + let maxN: number = cardStats.delayedDays.getMaxValue(); for (let dueOffset = 0; dueOffset <= maxN; dueOffset++) { - if (!Object.prototype.hasOwnProperty.call(this.plugin.dueDatesFlashcards, dueOffset)) { - this.plugin.dueDatesFlashcards[dueOffset] = 0; - } + cardStats.delayedDays.clearCountIfMissing(dueOffset); } const dueDatesFlashcardsCopy: Record = { 0: 0 }; - for (const [dueOffset, dueCount] of getTypedObjectEntries(this.plugin.dueDatesFlashcards)) { + for (const [dueOffset, dueCount] of getTypedObjectEntries(cardStats.delayedDays.dict)) { if (dueOffset <= 0) { dueDatesFlashcardsCopy[0] += dueCount; } else { @@ -92,7 +85,6 @@ export class StatsModal extends Modal { } } - const cardStats: Stats = this.plugin.cardStats; const scheduledCount: number = cardStats.youngCount + cardStats.matureCount; maxN = Math.max(maxN, 1); @@ -129,36 +121,27 @@ export class StatsModal extends Modal { t("NUMBER_OF_CARDS"), ); - maxN = Math.max(...getKeysPreserveType(cardStats.intervals)); + maxN = cardStats.intervals.getMaxValue(); for (let interval = 0; interval <= maxN; interval++) { - if (!Object.prototype.hasOwnProperty.call(cardStats.intervals, interval)) { - cardStats.intervals[interval] = 0; - } + cardStats.intervals.clearCountIfMissing(interval); } // Add intervals const average_interval: string = textInterval( Math.round( - (getTypedObjectEntries(cardStats.intervals) - .map(([interval, count]) => interval * count) - .reduce((a, b) => a + b, 0) / - scheduledCount) * - 10, + (cardStats.intervals.getTotalOfValueMultiplyCount() / scheduledCount) * 10, ) / 10 || 0, false, ), - longest_interval: string = textInterval( - Math.max(...getKeysPreserveType(cardStats.intervals)) || 0, - false, - ); + longest_interval: string = textInterval(cardStats.intervals.getMaxValue(), false); createStatsChart( "bar", "intervalsChart", t("INTERVALS"), t("INTERVALS_DESC"), - Object.keys(cardStats.intervals), - Object.values(cardStats.intervals), + Object.keys(cardStats.intervals.dict), + Object.values(cardStats.intervals.dict), t("INTERVALS_SUMMARY", { avg: average_interval, longest: longest_interval }), t("COUNT"), t("DAYS"), @@ -166,26 +149,20 @@ export class StatsModal extends Modal { ); // Add eases - const eases: number[] = getKeysPreserveType(cardStats.eases); + const eases: number[] = getKeysPreserveType(cardStats.eases.dict); for (let ease = Math.min(...eases); ease <= Math.max(...eases); ease++) { - if (!Object.prototype.hasOwnProperty.call(cardStats.eases, ease)) { - cardStats.eases[ease] = 0; - } + cardStats.eases.clearCountIfMissing(ease); } const average_ease: number = - Math.round( - getTypedObjectEntries(cardStats.eases) - .map(([ease, count]) => ease * count) - .reduce((a, b) => a + b, 0) / scheduledCount, - ) || 0; + Math.round(cardStats.eases.getTotalOfValueMultiplyCount() / scheduledCount) || 0; createStatsChart( "bar", "easesChart", t("EASES"), "", - Object.keys(cardStats.eases), - Object.values(cardStats.eases), + Object.keys(cardStats.eases.dict), + Object.values(cardStats.eases.dict), t("EASES_SUMMARY", { avgEase: average_ease }), t("COUNT"), t("EASES"), @@ -193,7 +170,7 @@ export class StatsModal extends Modal { ); // Add card types - const totalCardsCount: number = this.plugin.deckTree.totalFlashcards; + const totalCardsCount: number = this.plugin.deckTree.getCardCount(CardListType.All, true); createStatsChart( "pie", "cardTypesChart", diff --git a/src/main.ts b/src/main.ts index 5a5038f4..80c4c6d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,30 +1,39 @@ -import { - Notice, - Plugin, - TAbstractFile, - TFile, - HeadingCache, - getAllTags, - FrontMatterCache, -} from "obsidian"; +import { Notice, Plugin, TAbstractFile, TFile, getAllTags, FrontMatterCache } from "obsidian"; import * as graph from "pagerank.js"; import { SRSettingTab, SRSettings, DEFAULT_SETTINGS } from "src/settings"; -import { FlashcardModal, Deck } from "src/flashcard-modal"; -import { StatsModal, Stats } from "src/stats-modal"; -import { ReviewQueueListView, REVIEW_QUEUE_VIEW_TYPE } from "src/sidebar"; -import { Card, CardType, ReviewResponse, schedule } from "src/scheduling"; -import { - YAML_FRONT_MATTER_REGEX, - SCHEDULING_INFO_REGEX, - LEGACY_SCHEDULING_EXTRACTOR, - MULTI_SCHEDULING_EXTRACTOR, -} from "src/constants"; -import { escapeRegexString, cyrb53 } from "src/utils"; -import { ReviewDeck, ReviewDeckSelectionModal } from "src/review-deck"; +import { FlashcardModal } from "src/gui/flashcard-modal"; +import { StatsModal } from "src/gui/stats-modal"; +import { ReviewQueueListView, REVIEW_QUEUE_VIEW_TYPE } from "src/gui/sidebar"; +import { ReviewResponse, schedule } from "src/scheduling"; +import { YAML_FRONT_MATTER_REGEX, SCHEDULING_INFO_REGEX } from "src/constants"; +import { ReviewDeck, ReviewDeckSelectionModal } from "src/ReviewDeck"; import { t } from "src/lang/helpers"; -import { parse } from "src/parser"; import { appIcon } from "src/icons/appicon"; +import { TopicPath } from "./TopicPath"; +import { CardListType, Deck, DeckTreeFilter } from "./Deck"; +import { Stats } from "./stats"; +import { + FlashcardReviewMode, + FlashcardReviewSequencer as FlashcardReviewSequencer, + IFlashcardReviewSequencer as IFlashcardReviewSequencer, +} from "./FlashcardReviewSequencer"; +import { + CardListOrder, + DeckTreeIterator, + IDeckTreeIterator, + IIteratorOrder, + IteratorDeckSource, + OrderMethod, +} from "./DeckTreeIterator"; +import { CardScheduleCalculator } from "./CardSchedule"; +import { Note } from "./Note"; +import { NoteFileLoader } from "./NoteFileLoader"; +import { ISRFile, SrTFile as SrTFile } from "./SRFile"; +import { NoteEaseCalculator } from "./NoteEaseCalculator"; +import { DeckTreeStatsCalculator } from "./DeckTreeStatsCalculator"; +import { NoteEaseList } from "./NoteEaseList"; +import { QuestionPostponementList } from "./QuestionPostponementList"; interface PluginData { settings: SRSettings; @@ -62,20 +71,25 @@ export default class SRPlugin extends Plugin { public reviewDecks: { [deckKey: string]: ReviewDeck } = {}; public lastSelectedReviewDeck: string; - public newNotes: TFile[] = []; - public scheduledNotes: SchedNote[] = []; - public easeByPath: Record = {}; + public easeByPath: NoteEaseList; + private questionPostponementList: QuestionPostponementList; private incomingLinks: Record = {}; private pageranks: Record = {}; private dueNotesCount = 0; public dueDatesNotes: Record = {}; // Record<# of days in future, due count> public deckTree: Deck = new Deck("root", null); - public dueDatesFlashcards: Record = {}; // Record<# of days in future, due count> + private remainingDeckTree: Deck; public cardStats: Stats; async onload(): Promise { await this.loadPluginData(); + this.easeByPath = new NoteEaseList(this.data.settings); + this.questionPostponementList = new QuestionPostponementList( + this, + this.data.settings, + this.data.buryList, + ); appIcon(); @@ -93,7 +107,11 @@ export default class SRPlugin extends Plugin { this.addRibbonIcon("SpacedRepIcon", t("REVIEW_CARDS"), async () => { if (!this.syncLock) { await this.sync(); - new FlashcardModal(this.app, this).open(); + this.openFlashcardModal( + this.deckTree, + this.remainingDeckTree, + FlashcardReviewMode.Review, + ); } }); @@ -179,7 +197,11 @@ export default class SRPlugin extends Plugin { callback: async () => { if (!this.syncLock) { await this.sync(); - new FlashcardModal(this.app, this).open(); + this.openFlashcardModal( + this.deckTree, + this.remainingDeckTree, + FlashcardReviewMode.Review, + ); } }, }); @@ -188,8 +210,8 @@ export default class SRPlugin extends Plugin { id: "srs-cram-flashcards", name: t("CRAM_ALL_CARDS"), callback: async () => { - await this.sync(true); - new FlashcardModal(this.app, this, true).open(); + await this.sync(); + this.openFlashcardModal(this.deckTree, this.deckTree, FlashcardReviewMode.Cram); }, }); @@ -199,10 +221,7 @@ export default class SRPlugin extends Plugin { callback: async () => { const openFile: TFile | null = this.app.workspace.getActiveFile(); if (openFile && openFile.extension === "md") { - this.deckTree = new Deck("root", null); - const deckPath: string[] = this.findDeckPath(openFile); - await this.findFlashcardsInNote(openFile, deckPath); - new FlashcardModal(this.app, this).open(); + this.openFlashcardModalForSingleNote(openFile, FlashcardReviewMode.Review); } }, }); @@ -213,10 +232,7 @@ export default class SRPlugin extends Plugin { callback: async () => { const openFile: TFile | null = this.app.workspace.getActiveFile(); if (openFile && openFile.extension === "md") { - this.deckTree = new Deck("root", null); - const deckPath: string[] = this.findDeckPath(openFile); - await this.findFlashcardsInNote(openFile, deckPath, false, true); - new FlashcardModal(this.app, this, true).open(); + this.openFlashcardModalForSingleNote(openFile, FlashcardReviewMode.Cram); } }, }); @@ -248,7 +264,55 @@ export default class SRPlugin extends Plugin { this.app.workspace.getLeavesOfType(REVIEW_QUEUE_VIEW_TYPE).forEach((leaf) => leaf.detach()); } - async sync(ignoreStats = false): Promise { + private async openFlashcardModalForSingleNote( + noteFile: TFile, + reviewMode: FlashcardReviewMode, + ): Promise { + const topicPath: TopicPath = this.findTopicPath(this.createSrTFile(noteFile)); + const note: Note = await this.loadNote(noteFile, topicPath); + + const deckTree = new Deck("root", null); + note.appendCardsToDeck(deckTree); + const remainingDeckTree = DeckTreeFilter.filterForRemainingCards( + this.questionPostponementList, + deckTree, + reviewMode, + ); + this.openFlashcardModal(deckTree, remainingDeckTree, reviewMode); + } + + private openFlashcardModal( + fullDeckTree: Deck, + remainingDeckTree: Deck, + reviewMode: FlashcardReviewMode, + ): void { + const deckIterator = SRPlugin.createDeckTreeIterator(this.data.settings); + const cardScheduleCalculator = new CardScheduleCalculator( + this.data.settings, + this.easeByPath, + ); + const reviewSequencer: IFlashcardReviewSequencer = new FlashcardReviewSequencer( + reviewMode, + deckIterator, + this.data.settings, + cardScheduleCalculator, + this.questionPostponementList, + ); + + reviewSequencer.setDeckTree(fullDeckTree, remainingDeckTree); + new FlashcardModal(this.app, this, this.data.settings, reviewSequencer, reviewMode).open(); + } + + private static createDeckTreeIterator(settings: SRSettings): IDeckTreeIterator { + const iteratorOrder: IIteratorOrder = { + deckOrder: OrderMethod.Sequential, + cardListOrder: CardListOrder.DueFirst, + cardOrder: settings.randomizeCardOrder ? OrderMethod.Random : OrderMethod.Sequential, + }; + return new DeckTreeIterator(iteratorOrder, IteratorDeckSource.UpdatedByIterator); + } + + async sync(): Promise { if (this.syncLock) { return; } @@ -256,7 +320,7 @@ export default class SRPlugin extends Plugin { // reset notes stuff graph.reset(); - this.easeByPath = {}; + this.easeByPath = new NoteEaseList(this.data.settings); this.incomingLinks = {}; this.pageranks = {}; this.dueNotesCount = 0; @@ -264,39 +328,31 @@ export default class SRPlugin extends Plugin { this.reviewDecks = {}; // reset flashcards stuff - this.deckTree = new Deck("root", null); - this.dueDatesFlashcards = {}; - this.cardStats = { - eases: {}, - intervals: {}, - newCount: 0, - youngCount: 0, - matureCount: 0, - }; + const fullDeckTree = new Deck("root", null); const now = window.moment(Date.now()); const todayDate: string = now.format("YYYY-MM-DD"); // clear bury list if we've changed dates if (todayDate !== this.data.buryDate) { this.data.buryDate = todayDate; - this.data.buryList = []; + this.questionPostponementList.clear(); } const notes: TFile[] = this.app.vault.getMarkdownFiles(); - for (const note of notes) { + for (const noteFile of notes) { if ( this.data.settings.noteFoldersToIgnore.some((folder) => - note.path.startsWith(folder), + noteFile.path.startsWith(folder), ) ) { continue; } - if (this.incomingLinks[note.path] === undefined) { - this.incomingLinks[note.path] = []; + if (this.incomingLinks[noteFile.path] === undefined) { + this.incomingLinks[noteFile.path] = []; } - const links = this.app.metadataCache.resolvedLinks[note.path] || {}; + const links = this.app.metadataCache.resolvedLinks[noteFile.path] || {}; for (const targetPath in links) { if (this.incomingLinks[targetPath] === undefined) this.incomingLinks[targetPath] = []; @@ -304,29 +360,29 @@ export default class SRPlugin extends Plugin { // markdown files only if (targetPath.split(".").pop().toLowerCase() === "md") { this.incomingLinks[targetPath].push({ - sourcePath: note.path, + sourcePath: noteFile.path, linkCount: links[targetPath], }); - graph.link(note.path, targetPath, links[targetPath]); + graph.link(noteFile.path, targetPath, links[targetPath]); } } - const deckPath: string[] = this.findDeckPath(note); - if (deckPath.length !== 0) { - const flashcardsInNoteAvgEase: number = await this.findFlashcardsInNote( + const topicPath: TopicPath = this.findTopicPath(this.createSrTFile(noteFile)); + if (topicPath.hasPath) { + const note: Note = await this.loadNote(noteFile, topicPath); + const flashcardsInNoteAvgEase: number = NoteEaseCalculator.Calculate( note, - deckPath, - false, - ignoreStats, + this.data.settings, ); + note.appendCardsToDeck(fullDeckTree); if (flashcardsInNoteAvgEase > 0) { - this.easeByPath[note.path] = flashcardsInNoteAvgEase; + this.easeByPath.setEaseForPath(note.filePath, flashcardsInNoteAvgEase); } } - const fileCachedData = this.app.metadataCache.getFileCache(note) || {}; + const fileCachedData = this.app.metadataCache.getFileCache(noteFile) || {}; const frontmatter: FrontMatterCache | Record = fileCachedData.frontmatter || {}; @@ -358,7 +414,7 @@ export default class SRPlugin extends Plugin { ) ) { for (const matchedNoteTag of matchedNoteTags) { - this.reviewDecks[matchedNoteTag].newNotes.push(note); + this.reviewDecks[matchedNoteTag].newNotes.push(noteFile); } continue; } @@ -368,18 +424,19 @@ export default class SRPlugin extends Plugin { .valueOf(); for (const matchedNoteTag of matchedNoteTags) { - this.reviewDecks[matchedNoteTag].scheduledNotes.push({ note, dueUnix }); + this.reviewDecks[matchedNoteTag].scheduledNotes.push({ note: noteFile, dueUnix }); if (dueUnix <= now.valueOf()) { this.reviewDecks[matchedNoteTag].dueNotesCount++; } } - if (Object.prototype.hasOwnProperty.call(this.easeByPath, note.path)) { - this.easeByPath[note.path] = - (this.easeByPath[note.path] + frontmatter["sr-ease"]) / 2; + let ease: number; + if (this.easeByPath.hasEaseForPath(noteFile.path)) { + ease = (this.easeByPath.getEaseByPath(noteFile.path) + frontmatter["sr-ease"]) / 2; } else { - this.easeByPath[note.path] = frontmatter["sr-ease"]; + ease = frontmatter["sr-ease"]; } + this.easeByPath.setEaseForPath(noteFile.path, ease); if (dueUnix <= now.valueOf()) { this.dueNotesCount++; @@ -396,10 +453,21 @@ export default class SRPlugin extends Plugin { this.pageranks[node] = rank * 10000; }); + // Reviewable cards are all except those with the "edit later" tag + this.deckTree = DeckTreeFilter.filterForReviewableCards(fullDeckTree); + // sort the deck names this.deckTree.sortSubdecksList(); + this.remainingDeckTree = DeckTreeFilter.filterForRemainingCards( + this.questionPostponementList, + this.deckTree, + FlashcardReviewMode.Review, + ); + const calc: DeckTreeStatsCalculator = new DeckTreeStatsCalculator(); + this.cardStats = calc.calculate(this.deckTree); + if (this.data.settings.showDebugMessages) { - console.log(`SR: ${t("EASES")}`, this.easeByPath); + console.log(`SR: ${t("EASES")}`, this.easeByPath.dict); console.log(`SR: ${t("DECKS")}`, this.deckTree); } @@ -419,7 +487,7 @@ export default class SRPlugin extends Plugin { this.statusBar.setText( t("STATUS_BAR", { dueNotesCount: this.dueNotesCount, - dueFlashcardsCount: this.deckTree.dueFlashcardsCount, + dueFlashcardsCount: this.remainingDeckTree.getCardCount(CardListType.All, true), }), ); @@ -427,6 +495,13 @@ export default class SRPlugin extends Plugin { this.syncLock = false; } + async loadNote(noteFile: TFile, topicPath: TopicPath): 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); + return note; + } + async saveReviewResponse(note: TFile, response: ReviewResponse): Promise { const fileCachedData = this.app.metadataCache.getFileCache(note) || {}; const frontmatter: FrontMatterCache | Record = @@ -471,7 +546,7 @@ export default class SRPlugin extends Plugin { totalLinkCount = 0; for (const statObj of this.incomingLinks[note.path] || []) { - const ease: number = this.easeByPath[statObj.sourcePath]; + const ease: number = this.easeByPath.getEaseByPath(statObj.sourcePath); if (ease) { linkTotal += statObj.linkCount * this.pageranks[statObj.sourcePath] * ease; linkPGTotal += this.pageranks[statObj.sourcePath] * statObj.linkCount; @@ -481,7 +556,7 @@ export default class SRPlugin extends Plugin { const outgoingLinks = this.app.metadataCache.resolvedLinks[note.path] || {}; for (const linkedFilePath in outgoingLinks) { - const ease: number = this.easeByPath[linkedFilePath]; + const ease: number = this.easeByPath.getEaseByPath(linkedFilePath); if (ease) { linkTotal += outgoingLinks[linkedFilePath] * this.pageranks[linkedFilePath] * ease; @@ -500,7 +575,7 @@ export default class SRPlugin extends Plugin { : linkContribution * this.data.settings.baseEase); // add note's average flashcard ease if available if (Object.prototype.hasOwnProperty.call(this.easeByPath, note.path)) { - ease = (ease + this.easeByPath[note.path]) / 2; + ease = (ease + this.easeByPath.getEaseByPath(note.path)) / 2; } ease = Math.round(ease); interval = 1.0; @@ -553,7 +628,11 @@ export default class SRPlugin extends Plugin { } if (this.data.settings.burySiblingCards) { - await this.findFlashcardsInNote(note, [], true); // bury all cards in current note + const topicPath: TopicPath = this.findTopicPath(this.createSrTFile(note)); + const noteX: Note = await this.loadNote(note, topicPath); + for (const question of noteX.questionList) { + this.data.buryList.push(question.questionText.textHash); + } await this.savePluginData(); } await this.app.vault.modify(note, fileText); @@ -605,287 +684,12 @@ export default class SRPlugin extends Plugin { new Notice(t("ALL_CAUGHT_UP")); } - findDeckPath(note: TFile): string[] { - let deckPath: string[] = []; - if (this.data.settings.convertFoldersToDecks) { - deckPath = note.path.split("/"); - deckPath.pop(); // remove filename - if (deckPath.length === 0) { - deckPath = ["/"]; - } - } else { - const fileCachedData = this.app.metadataCache.getFileCache(note) || {}; - const tags = getAllTags(fileCachedData) || []; - - outer: for (const tagToReview of this.data.settings.flashcardTags) { - for (const tag of tags) { - if (tag === tagToReview || tag.startsWith(tagToReview + "/")) { - deckPath = tag.substring(1).split("/"); - break outer; - } - } - } - } - - return deckPath; + createSrTFile(note: TFile): SrTFile { + return new SrTFile(this.app.vault, this.app.metadataCache, note); } - async findFlashcardsInNote( - note: TFile, - deckPath: string[], - buryOnly = false, - ignoreStats = false, - ): Promise { - let fileText: string = await this.app.vault.read(note); - const fileCachedData = this.app.metadataCache.getFileCache(note) || {}; - const headings: HeadingCache[] = fileCachedData.headings || []; - let fileChanged = false, - totalNoteEase = 0, - scheduledCount = 0; - const settings: SRSettings = this.data.settings; - const noteDeckPath = deckPath; - - const now: number = Date.now(); - const parsedCards: [CardType, string, number][] = parse( - fileText, - settings.singleLineCardSeparator, - settings.singleLineReversedCardSeparator, - settings.multilineCardSeparator, - settings.multilineReversedCardSeparator, - settings.convertHighlightsToClozes, - settings.convertBoldTextToClozes, - settings.convertCurlyBracketsToClozes, - ); - for (const parsedCard of parsedCards) { - deckPath = noteDeckPath; - const cardType: CardType = parsedCard[0], - lineNo: number = parsedCard[2]; - let cardText: string = parsedCard[1]; - - if (cardText.includes(settings.editLaterTag)) { - continue; - } - - if (!settings.convertFoldersToDecks) { - const tagInCardRegEx = /^#[^\s#]+/gi; - const cardDeckPath = cardText - .match(tagInCardRegEx) - ?.slice(-1)[0] - .replace("#", "") - .split("/"); - if (cardDeckPath) { - deckPath = cardDeckPath; - cardText = cardText.replaceAll(tagInCardRegEx, ""); - } - } - - this.deckTree.createDeck([...deckPath]); - - const cardTextHash: string = cyrb53(cardText); - - if (buryOnly) { - this.data.buryList.push(cardTextHash); - continue; - } - - const siblingMatches: [string, string][] = []; - if (cardType === CardType.Cloze) { - const siblings: RegExpMatchArray[] = []; - if (settings.convertHighlightsToClozes) { - siblings.push(...cardText.matchAll(/==(.*?)==/gm)); - } - if (settings.convertBoldTextToClozes) { - siblings.push(...cardText.matchAll(/\*\*(.*?)\*\*/gm)); - } - if (settings.convertCurlyBracketsToClozes) { - siblings.push(...cardText.matchAll(/{{(.*?)}}/gm)); - } - siblings.sort((a, b) => { - if (a.index < b.index) { - return -1; - } - if (a.index > b.index) { - return 1; - } - return 0; - }); - - let front: string, back: string; - for (const m of siblings) { - const deletionStart: number = m.index, - deletionEnd: number = deletionStart + m[0].length; - front = - cardText.substring(0, deletionStart) + - "[...]" + - cardText.substring(deletionEnd); - front = front - .replace(/==/gm, "") - .replace(/\*\*/gm, "") - .replace(/{{/gm, "") - .replace(/}}/gm, ""); - back = - cardText.substring(0, deletionStart) + - "" + - cardText.substring(deletionStart, deletionEnd) + - "" + - cardText.substring(deletionEnd); - back = back - .replace(/==/gm, "") - .replace(/\*\*/gm, "") - .replace(/{{/gm, "") - .replace(/}}/gm, ""); - siblingMatches.push([front, back]); - } - } else { - let idx: number; - if (cardType === CardType.SingleLineBasic) { - idx = cardText.indexOf(settings.singleLineCardSeparator); - siblingMatches.push([ - cardText.substring(0, idx), - cardText.substring(idx + settings.singleLineCardSeparator.length), - ]); - } else if (cardType === CardType.SingleLineReversed) { - idx = cardText.indexOf(settings.singleLineReversedCardSeparator); - const side1: string = cardText.substring(0, idx), - side2: string = cardText.substring( - idx + settings.singleLineReversedCardSeparator.length, - ); - siblingMatches.push([side1, side2]); - siblingMatches.push([side2, side1]); - } else if (cardType === CardType.MultiLineBasic) { - idx = cardText.indexOf("\n" + settings.multilineCardSeparator + "\n"); - siblingMatches.push([ - cardText.substring(0, idx), - cardText.substring(idx + 2 + settings.multilineCardSeparator.length), - ]); - } else if (cardType === CardType.MultiLineReversed) { - idx = cardText.indexOf("\n" + settings.multilineReversedCardSeparator + "\n"); - const side1: string = cardText.substring(0, idx), - side2: string = cardText.substring( - idx + 2 + settings.multilineReversedCardSeparator.length, - ); - siblingMatches.push([side1, side2]); - siblingMatches.push([side2, side1]); - } - } - - let scheduling: RegExpMatchArray[] = [...cardText.matchAll(MULTI_SCHEDULING_EXTRACTOR)]; - if (scheduling.length === 0) - scheduling = [...cardText.matchAll(LEGACY_SCHEDULING_EXTRACTOR)]; - - // we have some extra scheduling dates to delete - if (scheduling.length > siblingMatches.length) { - const idxSched: number = cardText.lastIndexOf(""; - - const replacementRegex = new RegExp(escapeRegexString(cardText), "gm"); - fileText = fileText.replace(replacementRegex, () => newCardText); - fileChanged = true; - } - - const context: string = settings.showContextInCards - ? getCardContext(lineNo, headings, note.basename) - : ""; - const siblings: Card[] = []; - for (let i = 0; i < siblingMatches.length; i++) { - const front: string = siblingMatches[i][0].trim(), - back: string = siblingMatches[i][1].trim(); - - const cardObj: Card = { - isDue: i < scheduling.length, - note, - lineNo, - front, - back, - cardText, - context, - cardType, - siblingIdx: i, - siblings, - editLater: false, - }; - - // card scheduled - if (ignoreStats) { - this.cardStats.newCount++; - cardObj.isDue = true; - this.deckTree.insertFlashcard([...deckPath], cardObj); - } else if (i < scheduling.length) { - const dueUnix: number = window - .moment(scheduling[i][1], ["YYYY-MM-DD", "DD-MM-YYYY"]) - .valueOf(); - const nDays: number = Math.ceil((dueUnix - now) / (24 * 3600 * 1000)); - if (!Object.prototype.hasOwnProperty.call(this.dueDatesFlashcards, nDays)) { - this.dueDatesFlashcards[nDays] = 0; - } - this.dueDatesFlashcards[nDays]++; - - const interval: number = parseInt(scheduling[i][2]), - ease: number = parseInt(scheduling[i][3]); - if (!Object.prototype.hasOwnProperty.call(this.cardStats.intervals, interval)) { - this.cardStats.intervals[interval] = 0; - } - this.cardStats.intervals[interval]++; - if (!Object.prototype.hasOwnProperty.call(this.cardStats.eases, ease)) { - this.cardStats.eases[ease] = 0; - } - this.cardStats.eases[ease]++; - totalNoteEase += ease; - scheduledCount++; - - if (interval >= 32) { - this.cardStats.matureCount++; - } else { - this.cardStats.youngCount++; - } - - if (this.data.buryList.includes(cardTextHash)) { - this.deckTree.countFlashcard([...deckPath]); - continue; - } - - if (dueUnix <= now) { - cardObj.interval = interval; - cardObj.ease = ease; - cardObj.delayBeforeReview = now - dueUnix; - this.deckTree.insertFlashcard([...deckPath], cardObj); - } else { - this.deckTree.countFlashcard([...deckPath]); - continue; - } - } else { - this.cardStats.newCount++; - if (this.data.buryList.includes(cyrb53(cardText))) { - this.deckTree.countFlashcard([...deckPath]); - continue; - } - this.deckTree.insertFlashcard([...deckPath], cardObj); - } - - siblings.push(cardObj); - } - } - - if (fileChanged) { - await this.app.vault.modify(note, fileText); - } - - if (scheduledCount > 0) { - const flashcardsInNoteAvgEase: number = totalNoteEase / scheduledCount; - const flashcardContribution: number = Math.min( - 1.0, - Math.log(scheduledCount + 0.5) / Math.log(64), - ); - return ( - flashcardsInNoteAvgEase * flashcardContribution + - settings.baseEase * (1.0 - flashcardContribution) - ); - } - - return 0; + findTopicPath(note: ISRFile): TopicPath { + return TopicPath.getTopicPathOfFile(note, this.data.settings); } async loadPluginData(): Promise { @@ -914,25 +718,3 @@ export default class SRPlugin extends Plugin { } } } - -function getCardContext(cardLine: number, headings: HeadingCache[], note_title: string): string { - const stack: HeadingCache[] = []; - for (const heading of headings) { - if (heading.position.start.line > cardLine) { - break; - } - - while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) { - stack.pop(); - } - - stack.push(heading); - } - - let context = `${note_title} > `; - for (const headingObj of stack) { - headingObj.heading = headingObj.heading.replace(/\[\^\d+\]/gm, "").trim(); - context += `${headingObj.heading} > `; - } - return context.slice(0, -3); -} diff --git a/src/parser.ts b/src/parser.ts index a101918b..b1701089 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,4 +1,4 @@ -import { CardType } from "src/scheduling"; +import { CardType } from "./Question"; /** * Returns flashcards found in `text` diff --git a/src/scheduling.ts b/src/scheduling.ts index 2701884a..677f107d 100644 --- a/src/scheduling.ts +++ b/src/scheduling.ts @@ -1,5 +1,3 @@ -import { TFile } from "obsidian"; - import { SRSettings } from "src/settings"; import { t } from "src/lang/helpers"; @@ -12,36 +10,6 @@ export enum ReviewResponse { // Flashcards -export interface Card { - editLater: boolean; - // scheduling - isDue: boolean; - interval?: number; - ease?: number; - delayBeforeReview?: number; - // note - note: TFile; - lineNo: number; - // visuals - front: string; - back: string; - cardText: string; - context: string; - // types - cardType: CardType; - // information for sibling cards - siblingIdx: number; - siblings: Card[]; -} - -export enum CardType { - SingleLineBasic, - SingleLineReversed, - MultiLineBasic, - MultiLineReversed, - Cloze, -} - export function schedule( response: ReviewResponse, interval: number, diff --git a/src/stats.ts b/src/stats.ts new file mode 100644 index 00000000..edadf19d --- /dev/null +++ b/src/stats.ts @@ -0,0 +1,42 @@ +import { ValueCountDict } from "./util/NumberCountDict"; + +export class Stats { + eases: ValueCountDict = new ValueCountDict(); + intervals: ValueCountDict = new ValueCountDict(); + delayedDays: ValueCountDict = new ValueCountDict(); + newCount: number = 0; + youngCount: number = 0; + matureCount: number = 0; + + get totalCount(): number { + return this.youngCount + this.matureCount; + } + + incrementNew() { + this.newCount++; + } + + update(delayedDays: number, interval: number, ease: number) { + this.intervals.incrementCount(interval); + this.eases.incrementCount(ease); + this.delayedDays.incrementCount(delayedDays); + + if (interval >= 32) { + this.matureCount++; + } else { + this.youngCount++; + } + } + + getMaxInterval(): number { + return this.intervals.getMaxValue(); + } + + getAverageInterval(): number { + return this.intervals.getTotalOfValueMultiplyCount() / this.totalCount; + } + + getAverageEases(): number { + return this.eases.getTotalOfValueMultiplyCount() / this.totalCount; + } +} diff --git a/src/util/DateProvider.ts b/src/util/DateProvider.ts new file mode 100644 index 00000000..1674c60b --- /dev/null +++ b/src/util/DateProvider.ts @@ -0,0 +1,41 @@ +import moment from "moment"; +import { Moment } from "moment"; +import { ALLOWED_DATE_FORMATS } from "src/constants"; + +export interface IDateProvider { + get today(): Moment; +} + +export class LiveDateProvider implements IDateProvider { + get today(): Moment { + return moment().startOf("day"); + } +} + +export class StaticDateProvider implements IDateProvider { + private moment: Moment; + + constructor(moment: Moment) { + this.moment = moment; + } + + get today(): Moment { + return this.moment.clone(); + } + + static fromDateStr(str: string): StaticDateProvider { + return new StaticDateProvider(DateUtil.dateStrToMoment(str)); + } +} + +export class DateUtil { + static dateStrToMoment(str: string): Moment { + return moment(str, ALLOWED_DATE_FORMATS); + } +} + +export let globalDateProvider: IDateProvider = new LiveDateProvider(); + +export function setupStaticDateProvider_20230906() { + globalDateProvider = StaticDateProvider.fromDateStr("2023-09-06"); +} diff --git a/src/util/MultiLineTextFinder.ts b/src/util/MultiLineTextFinder.ts new file mode 100644 index 00000000..bd5a43a9 --- /dev/null +++ b/src/util/MultiLineTextFinder.ts @@ -0,0 +1,45 @@ +import { splitTextIntoLineArray } from "./utils"; + +export class MultiLineTextFinder { + static findAndReplace( + sourceText: string, + searchText: string, + replacementText: string, + ): string | null { + let result: string = null; + if (sourceText.includes(searchText)) { + result = sourceText.replace(searchText, replacementText); + } else { + const sourceTextArray = splitTextIntoLineArray(sourceText); + const searchTextArray = splitTextIntoLineArray(searchText); + const lineNo: number = MultiLineTextFinder.find(sourceTextArray, searchTextArray); + if (lineNo) { + const replacementTextArray = splitTextIntoLineArray(replacementText); + const linesToRemove: number = searchTextArray.length; + sourceTextArray.splice(lineNo, linesToRemove, ...replacementTextArray); + result = sourceTextArray.join("\n"); + } + } + return result; + } + + static find(sourceText: string[], searchText: string[]): number | null { + let result: number = null; + let searchIdx: number = 0; + const maxSearchIdx: number = searchText.length - 1; + for (let sourceIdx = 0; sourceIdx < sourceText.length; sourceIdx++) { + const sourceLine: string = sourceText[sourceIdx].trim(); + const searchLine: string = searchText[searchIdx].trim(); + if (searchLine == sourceLine) { + if (searchIdx == maxSearchIdx) { + result = sourceIdx - searchIdx; + break; + } + searchIdx++; + } else { + searchIdx = 0; + } + } + return result; + } +} diff --git a/src/util/NumberCountDict.ts b/src/util/NumberCountDict.ts new file mode 100644 index 00000000..5d6d0cc7 --- /dev/null +++ b/src/util/NumberCountDict.ts @@ -0,0 +1,30 @@ +import { getKeysPreserveType, getTypedObjectEntries } from "./utils"; + +export class ValueCountDict { + dict: Record = {}; // Record + + clearCountIfMissing(value: number): void { + if (!this.hasValue(value)) this.dict[value] = 0; + } + + hasValue(value: number): boolean { + return Object.prototype.hasOwnProperty.call(this.dict, value); + } + + incrementCount(value: number): void { + this.clearCountIfMissing(value); + this.dict[value]++; + } + + getMaxValue(): number { + return Math.max(...getKeysPreserveType(this.dict)) || 0; + } + + getTotalOfValueMultiplyCount(): number { + const v: number = + getTypedObjectEntries(this.dict) + .map(([value, count]) => value * count) + .reduce((a, b) => a + b, 0) || 0; + return v; + } +} diff --git a/src/util/RandomNumberProvider.ts b/src/util/RandomNumberProvider.ts new file mode 100644 index 00000000..aa14f86d --- /dev/null +++ b/src/util/RandomNumberProvider.ts @@ -0,0 +1,40 @@ +export interface IRandomNumberProvider { + getInteger(lowerBound: number, upperBound: number): number; +} + +export class RandomNumberProvider implements IRandomNumberProvider { + getInteger(lowerBound: number, upperBound: number): number { + const range = upperBound - lowerBound + 1; + return Math.floor(Math.random() * range) + lowerBound; + } +} + +export class StaticRandomNumberProvider implements IRandomNumberProvider { + expectedLowerBound: number; + expectedUpperBound: number; + next: number; + + getInteger(lowerBound: number, upperBound: number): number { + if (lowerBound != this.expectedLowerBound || upperBound != this.expectedUpperBound) + throw `lowerBound: ${lowerBound}/${this.expectedLowerBound}, upperBound: ${upperBound}/${this.expectedUpperBound}`; + return this.next; + } +} + +export let globalRandomNumberProvider: IRandomNumberProvider = new RandomNumberProvider(); +export const staticRandomNumberProvider: StaticRandomNumberProvider = + new StaticRandomNumberProvider(); + +export interface IStaticRandom { + lower: number; + upper: number; + next: number; +} + +export function setupNextRandomNumber(info: IStaticRandom) { + staticRandomNumberProvider.expectedLowerBound = info.lower; + staticRandomNumberProvider.expectedUpperBound = info.upper; + staticRandomNumberProvider.next = info.next; + + globalRandomNumberProvider = staticRandomNumberProvider; +} diff --git a/src/util/RenderMarkdownWrapper.ts b/src/util/RenderMarkdownWrapper.ts new file mode 100644 index 00000000..f084a59f --- /dev/null +++ b/src/util/RenderMarkdownWrapper.ts @@ -0,0 +1,145 @@ +import { App, MarkdownRenderer, TFile } from "obsidian"; +import { AUDIO_FORMATS, IMAGE_FORMATS, VIDEO_FORMATS } from "../constants"; +import SRPlugin from "../main"; + +export class RenderMarkdownWrapper { + private app: App; + private notePath: string; + private plugin: SRPlugin; + + constructor(app: App, plugin: SRPlugin, notePath: string) { + this.app = app; + this.notePath = notePath; + this.plugin = plugin; + } + + // slightly modified version of the renderMarkdown function in + // https://github.com/mgmeyers/obsidian-kanban/blob/main/src/KanbanView.tsx + async renderMarkdownWrapper( + markdownString: string, + containerEl: HTMLElement, + recursiveDepth = 0, + ): Promise { + if (recursiveDepth > 4) return; + + MarkdownRenderer.renderMarkdown(markdownString, containerEl, this.notePath, this.plugin); + + containerEl.findAll(".internal-embed").forEach((el) => { + const link = this.parseLink(el.getAttribute("src")); + + // file does not exist, display dead link + if (!link.target) { + el.innerText = link.text; + } else if (link.target instanceof TFile) { + if (link.target.extension !== "md") { + this.embedMediaFile(el, link.target); + } else { + el.innerText = ""; + this.renderTransclude(el, link, recursiveDepth); + } + } + }); + } + + private parseLink(src: string) { + const linkComponentsRegex = + /^(?[^#^]+)?(?:#(?!\^)(?.+)|#\^(?.+)|#)?$/; + const matched = typeof src === "string" && src.match(linkComponentsRegex); + const file = matched.groups.file || this.notePath; + const target = this.plugin.app.metadataCache.getFirstLinkpathDest(file, this.notePath); + return { + text: matched[0], + file: matched.groups.file, + heading: matched.groups.heading, + blockId: matched.groups.blockId, + target: target, + }; + } + + private embedMediaFile(el: HTMLElement, target: TFile) { + el.innerText = ""; + if (IMAGE_FORMATS.includes(target.extension)) { + el.createEl( + "img", + { + attr: { + src: this.plugin.app.vault.getResourcePath(target), + }, + }, + (img) => { + if (el.hasAttribute("width")) + img.setAttribute("width", el.getAttribute("width")); + else img.setAttribute("width", "100%"); + if (el.hasAttribute("alt")) img.setAttribute("alt", el.getAttribute("alt")); + el.addEventListener( + "click", + (ev) => + ((ev.target as HTMLElement).style.minWidth = + (ev.target as HTMLElement).style.minWidth === "100%" + ? null + : "100%"), + ); + }, + ); + el.addClasses(["image-embed", "is-loaded"]); + } else if ( + AUDIO_FORMATS.includes(target.extension) || + VIDEO_FORMATS.includes(target.extension) + ) { + el.createEl( + AUDIO_FORMATS.includes(target.extension) ? "audio" : "video", + { + attr: { + controls: "", + src: this.plugin.app.vault.getResourcePath(target), + }, + }, + (audio) => { + if (el.hasAttribute("alt")) audio.setAttribute("alt", el.getAttribute("alt")); + }, + ); + el.addClasses(["media-embed", "is-loaded"]); + } else { + el.innerText = target.path; + } + } + + private async renderTransclude( + el: HTMLElement, + link: { + text: string; + file: string; + heading: string; + blockId: string; + target: TFile; + }, + recursiveDepth: number, + ) { + const cache = this.app.metadataCache.getCache(link.target.path); + const text = await this.app.vault.cachedRead(link.target); + let blockText; + if (link.heading) { + const clean = (s: string) => s.replace(/[\W\s]/g, ""); + const headingIndex = cache.headings?.findIndex( + (h) => clean(h.heading) === clean(link.heading), + ); + const heading = cache.headings[headingIndex]; + + const startAt = heading.position.start.offset; + const endAt = + cache.headings.slice(headingIndex + 1).find((h) => h.level <= heading.level) + ?.position?.start?.offset || text.length; + + blockText = text.substring(startAt, endAt); + } else if (link.blockId) { + const block = cache.blocks[link.blockId]; + const startAt = block.position.start.offset; + const endAt = block.position.end.offset; + blockText = text.substring(startAt, endAt); + } else { + blockText = text; + } + + this.renderMarkdownWrapper(blockText, el, recursiveDepth + 1); + } +} diff --git a/src/utils.ts b/src/util/utils.ts similarity index 74% rename from src/utils.ts rename to src/util/utils.ts index 3d465d2d..1debe429 100644 --- a/src/utils.ts +++ b/src/util/utils.ts @@ -1,3 +1,7 @@ +import moment from "moment"; +import { Moment } from "moment"; +import { PREFERRED_DATE_FORMAT } from "src/constants"; + type Hex = number; // https://stackoverflow.com/a/69019874 @@ -54,3 +58,24 @@ export function cyrb53(str: string, seed = 0): string { h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16); } + +export function ticksFromDate(year: number, month: number, day: number): number { + return moment({ year, month, day }).utc().valueOf(); +} + +// 👇️ format as "YYYY-MM-DD" +// https://bobbyhadz.com/blog/typescript-date-format +export function formatDate_YYYY_MM_DD(ticks: Moment): string { + return ticks.format(PREFERRED_DATE_FORMAT); +} + +export function getAllTagsFromText(text: string): string[] { + const tagRegex = /#[^\s#]+/gi; + const result: RegExpMatchArray = text.match(tagRegex); + if (!result) return []; + return result; +} + +export function splitTextIntoLineArray(text: string): string[] { + return text.replaceAll("\r\n", "\n").split("\n"); +} diff --git a/tests/unit/DeckTreeIterator.test.ts b/tests/unit/DeckTreeIterator.test.ts new file mode 100644 index 00000000..92cb6f9f --- /dev/null +++ b/tests/unit/DeckTreeIterator.test.ts @@ -0,0 +1,497 @@ +import { NoteQuestionParser } from "src/NoteQuestionParser"; +import { CardListType, Deck } from "src/Deck"; +import { DEFAULT_SETTINGS } from "src/settings"; +import { SampleItemDecks } from "./SampleItems"; +import { TopicPath } from "src/TopicPath"; +import { + CardListOrder, + DeckTreeIterator, + IIteratorOrder, + IteratorDeckSource, + OrderMethod, +} from "src/DeckTreeIterator"; +import { + StaticDateProvider, + globalDateProvider, + setupStaticDateProvider_20230906, +} from "src/util/DateProvider"; +import { setupNextRandomNumber } from "src/util/RandomNumberProvider"; + +export var order_DueFirst_Sequential: IIteratorOrder = { + cardOrder: OrderMethod.Sequential, + cardListOrder: CardListOrder.DueFirst, + deckOrder: OrderMethod.Sequential, +}; +var order_DueFirst_Random: IIteratorOrder = { + cardOrder: OrderMethod.Random, + cardListOrder: CardListOrder.DueFirst, + deckOrder: OrderMethod.Sequential, +}; +var order_NewFirst_Sequential: IIteratorOrder = { + cardOrder: OrderMethod.Sequential, + cardListOrder: CardListOrder.NewFirst, + deckOrder: OrderMethod.Sequential, +}; + +var iterator: DeckTreeIterator; + +beforeAll(() => { + setupStaticDateProvider_20230906(); +}); + +describe("setDeck", () => { + test("currentDeck null immediately after setDeck", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3`; + let deck: Deck = await SampleItemDecks.createDeckFromText(text, new TopicPath(["Root"])); + let iterator: DeckTreeIterator = new DeckTreeIterator( + order_NewFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + expect(iterator.currentDeck).toEqual(null); + }); +}); + +describe("nextCard", () => { + describe("Sequential ordering", () => { + describe("Due cards before new cards", () => { + test("Single topic, new cards only", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3`; + let deck: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + let iterator: DeckTreeIterator = new DeckTreeIterator( + order_DueFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + + // No due cards, so expect the new ones immediately + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentDeck.deckName).toEqual("Root"); + expect(iterator.currentCard.front).toEqual("Q1"); + + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q2"); + + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q3"); + + expect(iterator.nextCard()).toEqual(false); + }); + + describe("Single topic, mixture of new and scheduled cards", () => { + test("Get the scheduled cards first", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3 + Q4::A4 + Q5::A5 + Q6::A6`; + let deck: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + iterator = new DeckTreeIterator( + order_DueFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + + // Scheduled cards first + nextCardThenCheck("Q2"); + nextCardThenCheck("Q4"); + nextCardThenCheck("Q5"); + + // New cards next + nextCardThenCheck("Q1"); + nextCardThenCheck("Q3"); + nextCardThenCheck("Q6"); + + // Check no more + expect(iterator.nextCard()).toEqual(false); + }); + }); + + describe("Multiple topics, mixture of new and scheduled cards", () => { + test("Get the ancestor deck's cards first, then descendants", async () => { + let text: string = ` + #flashcards Q1::A1 + #flashcards Q2::A2 + #flashcards Q3::A3 + + #flashcards/science Q4::A4 + #flashcards/science Q5::A5 + + #flashcards/science/physics Q6::A6 + #flashcards/science/physics Q7::A7 + + #flashcards/science/chemistry Q8::A8 + `; + let deck: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + iterator = new DeckTreeIterator( + order_DueFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + + // Due root deck's cards first + nextCardThenCheck("Q2"); + + // Then the new cards + nextCardThenCheck("Q1"); + nextCardThenCheck("Q3"); + + // Then subdeck #flashcards/science (due then new) + nextCardThenCheck("Q4"); + nextCardThenCheck("Q5"); + + // Then subdeck #flashcards/science/physics + nextCardThenCheck("Q6"); + nextCardThenCheck("Q7"); + + // Then subdeck #flashcards/science/chemistry + nextCardThenCheck("Q8"); + + // Check no more + expect(iterator.nextCard()).toEqual(false); + }); + }); + }); + + describe("New cards before due cards", () => { + test("Single topic, new cards only", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3`; + let deck: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + let iterator: DeckTreeIterator = new DeckTreeIterator( + order_NewFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentDeck.deckName).toEqual("Root"); + expect(iterator.currentCard.front).toEqual("Q1"); + + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q2"); + + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q3"); + + expect(iterator.nextCard()).toEqual(false); + }); + + describe("Single topic, mixture of new and scheduled cards", () => { + test("Get the new cards first", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3 + Q4::A4 + Q5::A5 + Q6::A6`; + let deck: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + iterator = new DeckTreeIterator( + order_NewFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + + // New cards first + nextCardThenCheck("Q1"); + nextCardThenCheck("Q3"); + nextCardThenCheck("Q6"); + + // Scheduled cards next + nextCardThenCheck("Q2"); + nextCardThenCheck("Q4"); + nextCardThenCheck("Q5"); + + // Check no more + expect(iterator.nextCard()).toEqual(false); + }); + + test("Get the scheduled cards first", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3 + Q4::A4 + Q5::A5 + Q6::A6`; + let deck: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + iterator = new DeckTreeIterator( + order_DueFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + + // Scheduled cards first + nextCardThenCheck("Q2"); + nextCardThenCheck("Q4"); + nextCardThenCheck("Q5"); + + // New cards next + nextCardThenCheck("Q1"); + nextCardThenCheck("Q3"); + nextCardThenCheck("Q6"); + + // Check no more + expect(iterator.nextCard()).toEqual(false); + }); + }); + + describe("Multiple topics, mixture of new and scheduled cards", () => { + test("Get the ancestor deck's cards first, then descendants", async () => { + let text: string = ` + #flashcards Q1::A1 + #flashcards Q2::A2 + #flashcards Q3::A3 + + #flashcards/science Q4::A4 + #flashcards/science Q5::A5 + + #flashcards/science/physics Q6::A6 + #flashcards/science/physics Q7::A7 + + #flashcards/science/chemistry Q8::A8 + `; + let deck: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + iterator = new DeckTreeIterator( + order_NewFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + + // New root deck's cards first + nextCardThenCheck("Q1"); + nextCardThenCheck("Q3"); + nextCardThenCheck("Q2"); + + // Then subdeck #flashcards/science + nextCardThenCheck("Q5"); + nextCardThenCheck("Q4"); + + // Then subdeck #flashcards/science/physics + nextCardThenCheck("Q6"); + nextCardThenCheck("Q7"); + + // Then subdeck #flashcards/science/chemistry + nextCardThenCheck("Q8"); + + // Check no more + expect(iterator.nextCard()).toEqual(false); + }); + }); + }); + }); + + describe("Random ordering", () => { + describe("Due cards before new cards", () => { + test("All new cards", async () => { + let text: string = ` + Q0::A0 + Q1::A1 + Q2::A2 + Q3::A3 + Q4::A4 + Q5::A5 + Q6::A6`; + let deck: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + iterator = new DeckTreeIterator( + order_DueFirst_Random, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + + // [0, 1, 2, 3, 4, 5, 6] + setupNextRandomNumber({ lower: 0, upper: 6, next: 5 }); + nextCardThenCheck("Q5"); + // [0, 1, 2, 3, 4, 6] + setupNextRandomNumber({ lower: 0, upper: 5, next: 5 }); + nextCardThenCheck("Q6"); + // [0, 1, 2, 3, 4] + setupNextRandomNumber({ lower: 0, upper: 4, next: 1 }); + nextCardThenCheck("Q1"); + // [0, 2, 3, 4] + setupNextRandomNumber({ lower: 0, upper: 3, next: 3 }); + nextCardThenCheck("Q4"); + // [0, 2, 3] + setupNextRandomNumber({ lower: 0, upper: 2, next: 1 }); + nextCardThenCheck("Q2"); + // [0, 3] + setupNextRandomNumber({ lower: 0, upper: 1, next: 1 }); + nextCardThenCheck("Q3"); + // [0] + setupNextRandomNumber({ lower: 0, upper: 0, next: 0 }); + nextCardThenCheck("Q0"); + + // Check no more + expect(iterator.nextCard()).toEqual(false); + }); + + test("Mixture new/scheduled", async () => { + let text: string = ` + QN0::A + QS0::A + QN1::A + QS1::A + QS2::A + QN2::A + QN3::A + QS3::Q `; + let deck: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + iterator = new DeckTreeIterator( + order_DueFirst_Random, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + + // Scheduled cards first + // [QN0, QN1, QN2, QN3], [QS0, QS1, QS2, QS3] + setupNextRandomNumber({ lower: 0, upper: 3, next: 3 }); + nextCardThenCheck("QS3"); + + // [QN0, QN1, QN2, QN3], [QS0, QS1, QS2] + setupNextRandomNumber({ lower: 0, upper: 2, next: 1 }); + nextCardThenCheck("QS1"); + + // [QN0, QN1, QN2, QN3], [QS0, QS2] + setupNextRandomNumber({ lower: 0, upper: 1, next: 0 }); + nextCardThenCheck("QS0"); + + // [QN0, QN1, QN2, QN3], [QS2] + setupNextRandomNumber({ lower: 0, upper: 0, next: 0 }); + nextCardThenCheck("QS2"); + + // New cards next + // [QN0, QN1, QN2, QN3] + setupNextRandomNumber({ lower: 0, upper: 3, next: 2 }); + nextCardThenCheck("QN2"); + + // [QN0, QN1, QN3] + setupNextRandomNumber({ lower: 0, upper: 2, next: 2 }); + nextCardThenCheck("QN3"); + + // [QN0, QN1] + setupNextRandomNumber({ lower: 0, upper: 1, next: 0 }); + nextCardThenCheck("QN0"); + + // [QN1] + setupNextRandomNumber({ lower: 0, upper: 0, next: 0 }); + nextCardThenCheck("QN1"); + + // Check no more + expect(iterator.nextCard()).toEqual(false); + }); + }); + }); +}); + +describe("hasCurrentCard", () => { + test("false immediately after setDeck", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3`; + let deck: Deck = await SampleItemDecks.createDeckFromText(text, new TopicPath(["Root"])); + let iterator: DeckTreeIterator = new DeckTreeIterator( + order_NewFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + expect(iterator.hasCurrentCard).toEqual(false); + }); + + test("true immediately after nextCard", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3`; + let deck: Deck = await SampleItemDecks.createDeckFromText(text, new TopicPath(["Root"])); + let iterator: DeckTreeIterator = new DeckTreeIterator( + order_NewFirst_Sequential, + IteratorDeckSource.UpdatedByIterator, + ); + iterator.setDeck(deck); + expect(iterator.nextCard()).toEqual(true); + expect(iterator.hasCurrentCard).toEqual(true); + }); +}); +/* describe("deleteCurrentCard", () => { + test("Delete after all cards iterated - exception throw", async () => { + let text: string = ` + Q1::A1 + Q2::A2 usim + Q3::A3`; + let deck: Deck = await SampleItemDecks.createDeckFromText(text, new TopicPath(["Root"])); + iterator = new DeckTreeSequentialIterator(order_NewFirst_Sequential); + iterator.setDeck(deck); + + expect(iterator.nextCard()).toEqual(true); + expect(iterator.nextCard()).toEqual(true); + expect(iterator.nextCard()).toEqual(true); + expect(iterator.nextCard()).toEqual(false); + + const t = () => { + iterator.deleteCurrentCard(); + }; + expect(t).toThrow(); + }); + + test("Delete card, with single card remaining after it", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3`; + let deck: Deck = await SampleItemDecks.createDeckFromText(text, new TopicPath(["Root"])); + expect(deck.newFlashcards.length).toEqual(3); + iterator = new DeckTreeSequentialIterator(order_NewFirst_Sequential); + iterator.setDeck(deck); + + nextCardThenCheck("Q1"); + nextCardThenCheck("Q2"); + expect(iterator.deleteCurrentCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual("Q3"); + expect(iterator.deleteCurrentCard()).toEqual(false); + }); + +}); + */ +function nextCardThenCheck(expectedFront: string): void { + expect(iterator.nextCard()).toEqual(true); + expect(iterator.currentCard.front).toEqual(expectedFront); +} diff --git a/tests/unit/FlashcardReviewSequencer.test.ts b/tests/unit/FlashcardReviewSequencer.test.ts new file mode 100644 index 00000000..86c1a76f --- /dev/null +++ b/tests/unit/FlashcardReviewSequencer.test.ts @@ -0,0 +1,825 @@ +import { CardScheduleCalculator } from "src/CardSchedule"; +import { + DeckTreeIterator, + IDeckTreeIterator, + IIteratorOrder, + IteratorDeckSource, +} from "src/DeckTreeIterator"; +import { + FlashcardReviewMode, + FlashcardReviewSequencer, + IFlashcardReviewSequencer, +} from "src/FlashcardReviewSequencer"; +import { TopicPath } from "src/TopicPath"; +import { CardListType, Deck } 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_20230906 } from "src/util/DateProvider"; +import moment from "moment"; +import { INoteEaseList, NoteEaseList } from "src/NoteEaseList"; +import { QuestionPostponementList, IQuestionPostponementList } from "src/QuestionPostponementList"; +import { order_DueFirst_Sequential } from "./DeckTreeIterator.test"; + +class TestContext { + cardSequencer: IDeckTreeIterator; + noteEaseList: INoteEaseList; + cardScheduleCalculator: CardScheduleCalculator; + reviewSequencer: IFlashcardReviewSequencer; + questionPostponementList: QuestionPostponementList; + file: UnitTestSRFile; + originalText: string; + fakeFilePath: string; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + async setSequencerDeckTreeFromOriginalText(): Promise { + let deck: Deck = await SampleItemDecks.createDeckFromFile( + this.file, + new TopicPath(["Root"]), + ); + this.reviewSequencer.setDeckTree(deck, deck); + return deck; + } + + static Create( + iteratorOrder: IIteratorOrder, + reviewMode: FlashcardReviewMode, + settings: SRSettings, + text: string, + fakeFilePath?: string, + ): TestContext { + let cardSequencer: IDeckTreeIterator = new DeckTreeIterator( + iteratorOrder, + IteratorDeckSource.UpdatedByIterator, + ); + let noteEaseList = new NoteEaseList(settings); + let cardScheduleCalculator: CardScheduleCalculator = new CardScheduleCalculator( + settings, + noteEaseList, + ); + let cardPostponementList: QuestionPostponementList = new QuestionPostponementList( + null, + settings, + [], + ); + let reviewSequencer: IFlashcardReviewSequencer = new FlashcardReviewSequencer( + reviewMode, + cardSequencer, + settings, + cardScheduleCalculator, + cardPostponementList, + ); + var file: UnitTestSRFile = new UnitTestSRFile(text, fakeFilePath); + + let result: TestContext = new TestContext({ + cardSequencer, + noteEaseList, + cardScheduleCalculator, + reviewSequencer, + questionPostponementList: cardPostponementList, + file, + originalText: text, + fakeFilePath, + }); + return result; + } +} + +interface Info1 { + cardQ2_PreReviewText: string; + cardQ2_PostReviewEase: number; + cardQ2_PostReviewInterval: number; + cardQ2_PostReviewDueDate: string; + cardQ2_PostReviewText: string; +} + +async function checkReviewResponse_ReviewMode( + reviewResponse: ReviewResponse, + info: Info1, +): Promise { + let text: string = ` +#flashcards Q1::A1 +#flashcards Q2::A2 +#flashcards Q3::A3`; + + let fakeFilePath: string = moment().millisecond().toString(); + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Review, + DEFAULT_SETTINGS, + text, + fakeFilePath, + ); + let deck: Deck = await c.setSequencerDeckTreeFromOriginalText(); + + // State before calling processReview + let card = c.reviewSequencer.currentCard; + expect(card.front).toEqual("Q2"); + expect(card.scheduleInfo).toMatchObject({ + ease: 270, + interval: 4, + }); + + // State after calling processReview - next card + await c.reviewSequencer.processReview(reviewResponse); + expect(c.reviewSequencer.currentCard.front).toEqual("Q1"); + + // Schedule for the reviewed card has been updated + expect(card.scheduleInfo.ease).toEqual(info.cardQ2_PostReviewEase); + expect(card.scheduleInfo.interval).toEqual(info.cardQ2_PostReviewInterval); + expect(card.scheduleInfo.dueDate.unix).toEqual(moment(info.cardQ2_PostReviewDueDate).unix); + + // Note text has been updated + let expectedText: string = c.originalText.replace( + info.cardQ2_PreReviewText, + info.cardQ2_PostReviewText, + ); + expect(await c.file.read()).toEqual(expectedText); +} + +async function checkReviewResponse_CramMode(reviewResponse: ReviewResponse): Promise { + let text: string = ` +#flashcards Q1::A1 +#flashcards Q2::A2 +#flashcards Q3::A3 +#flashcards Q4::A4 `; + + let str: string = moment().millisecond().toString(); + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Cram, + DEFAULT_SETTINGS, + text, + str, + ); + let deck: Deck = await c.setSequencerDeckTreeFromOriginalText(); + + // State before calling processReview + let card = c.reviewSequencer.currentCard; + expect(card.front).toEqual("Q1"); + let expectInfo = { + ease: 270, + interval: 4, + }; + expect(card.scheduleInfo).toMatchObject(expectInfo); + + // State after calling processReview - next card + await c.reviewSequencer.processReview(reviewResponse); + expect(c.reviewSequencer.currentCard.front).toEqual("Q2"); + + // No change to schedule for reviewed card in cram mode + expect(card.scheduleInfo).toMatchObject(expectInfo); + expect(card.scheduleInfo.dueDate.unix).toEqual(moment("2023-09-02").unix); + + // Note text remains the same + let expectedText: string = c.originalText; + expect(await c.file.read()).toEqual(expectedText); + + return c; +} + +async function setupSample1( + reviewMode: FlashcardReviewMode, + settings: SRSettings, +): Promise { + let text: string = ` +#flashcards Q1::A1 + +#flashcards Q2::A2 + + +#flashcards Q3::A3 +#flashcards/science Q4::A4 +#flashcards/science/physics Q5::A5 +#flashcards/math Q6::A6`; + + let c: TestContext = TestContext.Create(order_DueFirst_Sequential, reviewMode, settings, text); + await c.setSequencerDeckTreeFromOriginalText(); + return c; +} + +async function setupSample2(reviewMode: FlashcardReviewMode): Promise { + let text: string = ` +#flashcards Q1::A1 + + +#flashcards Q2:::A2 + + +#flashcards Q3::A3 + + +#flashcards This single ==question== turns into ==3 separate== ==cards== + +`; + + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + reviewMode, + DEFAULT_SETTINGS, + text, + ); + await c.setSequencerDeckTreeFromOriginalText(); + return c; +} + +function skipThenCheckCardFront(sequencer: IFlashcardReviewSequencer, expectedFront: string): void { + sequencer.skipCurrentCard(); + expect(sequencer.currentCard.front).toEqual(expectedFront); +} + +////////////////////////////////////////////////////////////////////// + +beforeAll(() => { + setupStaticDateProvider_20230906(); +}); + +describe("setDeckTree", () => { + test("Empty deck", () => { + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Review, + DEFAULT_SETTINGS, + "", + ); + c.reviewSequencer.setDeckTree(Deck.emptyDeck, Deck.emptyDeck); + expect(c.reviewSequencer.currentDeck).toEqual(null); + expect(c.reviewSequencer.currentCard).toEqual(null); + }); + + // After setDeckTree, the first card in the deck is the current card + test("Single level deck with some new cards", async () => { + let text: string = ` +Q1::A1 +Q2::A2 +Q3::A3`; + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Review, + DEFAULT_SETTINGS, + text, + ); + let deck: Deck = await c.setSequencerDeckTreeFromOriginalText(); + expect(deck.newFlashcards.length).toEqual(3); + + expect(c.reviewSequencer.currentDeck.newFlashcards.length).toEqual(3); + let expected = { + front: "Q1", + back: "A1", + }; + expect(c.reviewSequencer.currentCard).toMatchObject(expected); + }); +}); + +describe("skipCurrentCard", () => { + test("Simple test", async () => { + let c: TestContext = await setupSample1(FlashcardReviewMode.Review, DEFAULT_SETTINGS); + expect(c.reviewSequencer.currentCard.front).toEqual("Q2"); + + // No more due cards after current card, so we expect the first new card for topic #flashcards + skipThenCheckCardFront(c.reviewSequencer, "Q1"); + skipThenCheckCardFront(c.reviewSequencer, "Q3"); + }); + + test("Skip repeatedly until no more", async () => { + let c: TestContext = await setupSample1(FlashcardReviewMode.Review, DEFAULT_SETTINGS); + expect(c.reviewSequencer.currentCard.front).toEqual("Q2"); + + // No more due cards after current card, so we expect the first new card for topic #flashcards + skipThenCheckCardFront(c.reviewSequencer, "Q1"); + skipThenCheckCardFront(c.reviewSequencer, "Q3"); + + skipThenCheckCardFront(c.reviewSequencer, "Q4"); + skipThenCheckCardFront(c.reviewSequencer, "Q5"); + skipThenCheckCardFront(c.reviewSequencer, "Q6"); + + c.reviewSequencer.skipCurrentCard(); + expect(c.reviewSequencer.hasCurrentCard).toEqual(false); + }); + + test("Skipping a card skips all sibling cards", async () => { + let text: string = ` +#flashcards Q1::A1 + + +#flashcards Q2:::A2 + + +#flashcards Q3::A3 + + +#flashcards This single ==question== turns into ==3 separate== ==cards== + +`; + + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Review, + DEFAULT_SETTINGS, + text, + ); + await c.setSequencerDeckTreeFromOriginalText(); + expect(c.reviewSequencer.currentQuestion.cards.length).toEqual(1); + expect(c.reviewSequencer.currentCard.front).toEqual("Q1"); + + skipThenCheckCardFront(c.reviewSequencer, "Q2"); + expect(c.reviewSequencer.currentQuestion.cards.length).toEqual(2); + + // Skipping Q2 skips over both Q2::A2 and A2::Q2, goes straight to Q3 + skipThenCheckCardFront(c.reviewSequencer, "Q3"); + expect(c.reviewSequencer.currentQuestion.cards.length).toEqual(1); + + // Skip over Q3 + c.reviewSequencer.skipCurrentCard(); + expect(c.reviewSequencer.currentQuestion.cards[0].front).toMatch(/This single/); + expect(c.reviewSequencer.currentQuestion.cards.length).toEqual(3); + + // Skip over the cloze, skips all 3 cards, no cards left + c.reviewSequencer.skipCurrentCard(); + expect(c.reviewSequencer.hasCurrentCard).toEqual(false); + }); + + describe("Checking postponement list", () => { + describe("FlashcardReviewMode.Review", () => { + test("burySiblingCards=false - skipped question not added to postponement list", async () => { + let settings: SRSettings = { ...DEFAULT_SETTINGS }; + settings.burySiblingCards = false; + + let c: TestContext = await setupSample1(FlashcardReviewMode.Review, settings); + expect(c.questionPostponementList.list.length).toEqual(0); + expect(c.reviewSequencer.currentCard.front).toEqual("Q2"); + + // Skip over these 2 questions + skipThenCheckCardFront(c.reviewSequencer, "Q1"); + skipThenCheckCardFront(c.reviewSequencer, "Q3"); + + expect(c.questionPostponementList.list.length).toEqual(0); + }); + + test("burySiblingCards=true - skipped question added to postponement list", async () => { + let settings: SRSettings = { ...DEFAULT_SETTINGS }; + settings.burySiblingCards = true; + + let c: TestContext = await setupSample1(FlashcardReviewMode.Review, settings); + expect(c.questionPostponementList.list.length).toEqual(0); + expect(c.reviewSequencer.currentCard.front).toEqual("Q2"); + + // Skip over 2 questions + skipThenCheckCardFront(c.reviewSequencer, "Q1"); + skipThenCheckCardFront(c.reviewSequencer, "Q3"); + + expect(c.questionPostponementList.list.length).toEqual(2); + }); + }); + + describe("FlashcardReviewMode.Cram", () => { + test("Cram mode - skipped question not added to postponement list", async () => { + let c: TestContext = await setupSample1(FlashcardReviewMode.Cram, DEFAULT_SETTINGS); + expect(c.questionPostponementList.list.length).toEqual(0); + expect(c.reviewSequencer.currentCard.front).toEqual("Q2"); + + // Skip over these questions + skipThenCheckCardFront(c.reviewSequencer, "Q1"); + skipThenCheckCardFront(c.reviewSequencer, "Q3"); + + expect(c.questionPostponementList.list.length).toEqual(0); + }); + }); + }); + // No postponement during cramming + // Deletion of sibling cards after text modification + // Deletion of sibling cards after card skip + // Delete+postpone +}); + +describe("processReview", () => { + describe("FlashcardReviewMode.Review", () => { + describe("ReviewResponse.Reset", () => { + test("Simple test - 3 cards all due in same deck - reset card moves to end of deck", async () => { + let text: string = ` + #flashcards Q1::A1 + #flashcards Q2::A2 + #flashcards Q3::A3 `; + + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Review, + DEFAULT_SETTINGS, + text, + ); + let deck: Deck = await c.setSequencerDeckTreeFromOriginalText(); + + // State before calling processReview + let card = c.reviewSequencer.currentCard; + expect(card.front).toEqual("Q1"); + expect(card.scheduleInfo).toMatchObject({ + ease: 270, + interval: 4, + }); + + // State after calling processReview - same current card + // (only need to check ease, interval - dueDate & delayBeforeReview are not relevant) + await c.reviewSequencer.processReview(ReviewResponse.Reset); + card = c.reviewSequencer.currentCard; + expect(card.front).toEqual("Q2"); + expect(card.scheduleInfo).toMatchObject({ + ease: 270, + interval: 5, + }); + + c.reviewSequencer.skipCurrentCard(); + card = c.reviewSequencer.currentCard; + expect(card.front).toEqual("Q3"); + expect(card.scheduleInfo).toMatchObject({ + ease: 270, + interval: 6, + }); + + // After skipping Q3, we should see Q1 the reset card with updated ease/interval + c.reviewSequencer.skipCurrentCard(); + card = c.reviewSequencer.currentCard; + expect(card.front).toEqual("Q1"); + expect(card.scheduleInfo).toMatchObject({ + ease: DEFAULT_SETTINGS.baseEase, + interval: 1, + }); + }); + }); + + describe("ReviewResponse.Easy", () => { + test("Card schedule is updated, next card becomes current", async () => { + const expected: Info1 = { + cardQ2_PreReviewText: "Q2::A2 ", + cardQ2_PostReviewEase: 290, + cardQ2_PostReviewInterval: 15, + cardQ2_PostReviewDueDate: "2023-09-21", // 15 days after the unit testing fixed date of 2023-09-06 + cardQ2_PostReviewText: `Q2::A2 +`, + }; + await checkReviewResponse_ReviewMode(ReviewResponse.Easy, expected); + }); + }); + }); + + describe("FlashcardReviewMode.Cram", () => { + describe("ReviewResponse.Easy", () => { + test("Next card after reviewed card becomes current; reviewed easy card doesn't resurface", async () => { + // [Q1, Q2, Q3] review Q1, then current becomes Q2 + let c: TestContext = await checkReviewResponse_CramMode(ReviewResponse.Easy); + expect(c.reviewSequencer.currentCard.front).toEqual("Q2"); + skipThenCheckCardFront(c.reviewSequencer, "Q3"); + skipThenCheckCardFront(c.reviewSequencer, "Q4"); + + c.reviewSequencer.skipCurrentCard(); + expect(c.reviewSequencer.hasCurrentCard).toEqual(false); + }); + }); + + describe("ReviewResponse.Hard", () => { + test("Next card after reviewed card becomes current; reviewed hard card seen again", async () => { + // [Q1, Q2, Q3] review Q1, then current becomes Q2 + let c: TestContext = await checkReviewResponse_CramMode(ReviewResponse.Hard); + expect(c.reviewSequencer.currentCard.front).toEqual("Q2"); + skipThenCheckCardFront(c.reviewSequencer, "Q3"); + skipThenCheckCardFront(c.reviewSequencer, "Q4"); + skipThenCheckCardFront(c.reviewSequencer, "Q1"); + + c.reviewSequencer.skipCurrentCard(); + expect(c.reviewSequencer.hasCurrentCard).toEqual(false); + }); + }); + }); +}); + +describe("updateCurrentQuestionText", () => { + let space: string = " "; + + describe("Checking update to file", () => { + describe("Single line card type; Settings - schedule on following line", () => { + test("Question has schedule on following line before/after update", async () => { + let text: string = ` +#flashcards Q1::A1 + +#flashcards Q2::A2 + + +#flashcards Q3::A3`; + + let updatedQ: string = "A much more in depth question::A much more detailed answer"; + let originalStr: string = `#flashcards Q2::A2 +`; + let updatedStr: string = `#flashcards A much more in depth question::A much more detailed answer +`; + await checkUpdateCurrentQuestionText( + text, + updatedQ, + originalStr, + updatedStr, + DEFAULT_SETTINGS, + ); + }); + + test("Question has schedule on same line (but pushed to following line due to settings)", async () => { + let text: string = ` +#flashcards Q1::A1 + +#flashcards Q2::A2 + +#flashcards Q3::A3`; + + let updatedQ: string = "A much more in depth question::A much more detailed answer"; + let originalStr: string = `#flashcards Q2::A2 `; + let expectedUpdatedStr: string = `#flashcards A much more in depth question::A much more detailed answer +`; + await checkUpdateCurrentQuestionText( + text, + updatedQ, + originalStr, + expectedUpdatedStr, + DEFAULT_SETTINGS, + ); + }); + }); + + describe("Single line card type; Settings - schedule on same line", () => { + let settings: SRSettings = { ...DEFAULT_SETTINGS }; + settings.cardCommentOnSameLine = true; + + test("Question has schedule on same line before/after", async () => { + let text1: string = ` +#flashcards Q1::A1 + +#flashcards Q2::A2 + +#flashcards Q3::A3`; + + let updatedQ: string = "A much more in depth question::A much more detailed answer"; + let originalStr: string = `#flashcards Q2::A2 `; + let updatedStr: string = `#flashcards A much more in depth question::A much more detailed answer `; + await checkUpdateCurrentQuestionText( + text1, + updatedQ, + originalStr, + updatedStr, + settings, + ); + }); + + test("Question has schedule on following line (but placed on same line due to settings)", async () => { + let text: string = ` +#flashcards Q1::A1 + +#flashcards Q2::A2 + + +#flashcards Q3::A3`; + + let updatedQ: string = "A much more in depth question::A much more detailed answer"; + let originalStr: string = `#flashcards Q2::A2 +`; + let updatedStr: string = `#flashcards A much more in depth question::A much more detailed answer `; + await checkUpdateCurrentQuestionText( + text, + updatedQ, + originalStr, + updatedStr, + settings, + ); + }); + }); + + describe("Multiline card type; Settings - schedule on following line", () => { + test("Question starts immediately after tag; Existing schedule present", async () => { + let originalStr: string = `Q2 +? +A2 +`; + + let text: string = ` +#flashcards Q1::A1 + +#flashcards ${originalStr} + +#flashcards Q3::A3`; + + let updatedQ: string = `Multiline question +Question starting immediately after tag +? +A2 (answer now includes more detail) +extra answer line 2`; + + let expectedUpdatedStr: string = `Multiline question +Question starting immediately after tag +? +A2 (answer now includes more detail) +extra answer line 2 +`; + + await checkUpdateCurrentQuestionText( + text, + updatedQ, + originalStr, + expectedUpdatedStr, + DEFAULT_SETTINGS, + ); + }); + + test("Question starts on same line as tag (after two spaces); Existing schedule present", async () => { + let originalStr: string = `Q2 +? +A2 +`; + + let text: string = ` +#flashcards Q1::A1 + +#flashcards${space}${space}${originalStr} + +#flashcards Q3::A3`; + + let updatedQ: string = `Multiline question +Question starting immediately after tag +? +A2 (answer now includes more detail) +extra answer line 2`; + + let expectedUpdatedStr: string = `Multiline question +Question starting immediately after tag +? +A2 (answer now includes more detail) +extra answer line 2 +`; + + await checkUpdateCurrentQuestionText( + text, + updatedQ, + originalStr, + expectedUpdatedStr, + DEFAULT_SETTINGS, + ); + }); + + test("Question starts line after tag; Existing schedule present", async () => { + let originalStr: string = `#flashcards +Q2 +? +A2 +`; + + let text: string = ` +#flashcards Q1::A1 + +${originalStr} + +#flashcards Q3::A3`; + + let updatedQ: string = `Multiline question +Question starting line after tag +? +A2 (answer now includes more detail) +extra answer line 2`; + + let expectedUpdatedStr: string = `#flashcards +Multiline question +Question starting line after tag +? +A2 (answer now includes more detail) +extra answer line 2 +`; + + await checkUpdateCurrentQuestionText( + text, + updatedQ, + originalStr, + expectedUpdatedStr, + DEFAULT_SETTINGS, + ); + }); + + test("Question starts line after tag (no white space after tag); New card", async () => { + let originalQuestionStr: string = `#flashcards +Q2 +? +A2`; + + let fileText: string = ` +${originalQuestionStr} + +#flashcards Q1::A1 + +#flashcards Q3::A3`; + + let updatedQuestionText: string = `Multiline question +Question starting immediately after tag +? +A2 (answer now includes more detail) +extra answer line 2`; + + let expectedUpdatedStr: string = `#flashcards +${updatedQuestionText}`; + + await checkUpdateCurrentQuestionText( + fileText, + updatedQuestionText, + originalQuestionStr, + expectedUpdatedStr, + DEFAULT_SETTINGS, + ); + }); + + test("Question starts line after tag (single space after tag before newline); New card", async () => { + let originalQuestionStr: string = `#flashcards${space} +Q2 +? +A2`; + + let fileText: string = ` +${originalQuestionStr} + +#flashcards Q1::A1 + +#flashcards Q3::A3`; + + let updatedQuestionText: string = `Multiline question +Question starting immediately after tag +? +A2 (answer now includes more detail) +extra answer line 2`; + + let expectedUpdatedStr: string = `#flashcards +${updatedQuestionText}`; + + await checkUpdateCurrentQuestionText( + fileText, + updatedQuestionText, + originalQuestionStr, + expectedUpdatedStr, + DEFAULT_SETTINGS, + ); + }); + }); + }); +}); + +describe("Sequences", () => { + test("Update question text, followed by review response", async () => { + let text1: string = ` +#flashcards Q2::A2 + +#flashcards Q3::A3`; + + // Do the update step + let updatedQ: string = "A much more in depth question::A much more detailed answer"; + let originalStr: string = `#flashcards Q2::A2`; + let updatedStr: string = `#flashcards A much more in depth question::A much more detailed answer`; + + let c: TestContext = await checkUpdateCurrentQuestionText( + text1, + updatedQ, + originalStr, + updatedStr, + DEFAULT_SETTINGS, + ); + + // Now do the review step + await c.reviewSequencer.processReview(ReviewResponse.Hard); + + // Schedule for the reviewed card has been updated + let expectedText: string = ` +${updatedStr} + + +#flashcards Q3::A3`; + + expect(await c.file.read()).toEqual(expectedText); + }); +}); + +async function checkUpdateCurrentQuestionText( + noteText: string, + updatedQ: string, + originalStr: string, + updatedStr: string, + settings: SRSettings, +): Promise { + let c: TestContext = TestContext.Create( + order_DueFirst_Sequential, + FlashcardReviewMode.Review, + settings, + noteText, + ); + await c.setSequencerDeckTreeFromOriginalText(); + expect(c.reviewSequencer.currentCard.front).toEqual("Q2"); + + await c.reviewSequencer.updateCurrentQuestionText(updatedQ); + + // originalText should remain the same except for the specific substring change from originalStr => updatedStr + if (!c.originalText.includes(originalStr)) throw `Text not found: ${originalStr}`; + let expectedFileText: string = c.originalText.replace(originalStr, updatedStr); + expect(await c.file.read()).toEqual(expectedFileText); + return c; +} diff --git a/tests/unit/Note.test.ts b/tests/unit/Note.test.ts new file mode 100644 index 00000000..96b59024 --- /dev/null +++ b/tests/unit/Note.test.ts @@ -0,0 +1,76 @@ +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"; + +let parser: NoteParser = new NoteParser(DEFAULT_SETTINGS); +var noteFileLoader: NoteFileLoader = new NoteFileLoader(DEFAULT_SETTINGS); + +describe("appendCardsToDeck", () => { + test("Multiple questions, single card per question", async () => { + let noteText: string = `#flashcards/test +Q1::A1 +Q2::A2 +Q3::A3 +`; + let file: UnitTestSRFile = new UnitTestSRFile(noteText); + let folderTopicPath = TopicPath.emptyPath; + let note: Note = await parser.parse(file, folderTopicPath); + let deck: Deck = Deck.emptyDeck; + note.appendCardsToDeck(deck); + let subdeck: Deck = deck.getDeck(new TopicPath(["flashcards", "test"])); + expect(subdeck.newFlashcards[0].front).toEqual("Q1"); + expect(subdeck.newFlashcards[1].front).toEqual("Q2"); + expect(subdeck.newFlashcards[2].front).toEqual("Q3"); + expect(subdeck.dueFlashcards.length).toEqual(0); + }); + + test("Multiple questions, multiple cards per question", async () => { + let noteText: string = `#flashcards/test +Q1:::A1 +Q2:::A2 +Q3:::A3 +`; + let file: UnitTestSRFile = new UnitTestSRFile(noteText); + let folderTopicPath = TopicPath.emptyPath; + let note: Note = await parser.parse(file, folderTopicPath); + let deck: Deck = Deck.emptyDeck; + note.appendCardsToDeck(deck); + let subdeck: Deck = deck.getDeck(new TopicPath(["flashcards", "test"])); + expect(subdeck.newFlashcards.length).toEqual(6); + let frontList = subdeck.newFlashcards.map((card) => card.front); + + expect(frontList).toEqual(["Q1", "A1", "Q2", "A2", "Q3", "A3"]); + expect(subdeck.dueFlashcards.length).toEqual(0); + }); +}); + +describe("writeNoteFile", () => { + test("Multiple questions, some with too many schedule details", async () => { + let originalText: string = `#flashcards/test +Q1::A1 +#flashcards Q2::A2 + +Q3:::A3 + +`; + let file: UnitTestSRFile = new UnitTestSRFile(originalText); + let note: Note = await noteFileLoader.load(file, TopicPath.emptyPath); + + await note.writeNoteFile(DEFAULT_SETTINGS); + let updatedText: string = file.content; + + let expectedText: string = `#flashcards/test +Q1::A1 +#flashcards Q2::A2 + +Q3:::A3 + +`; + expect(updatedText).toEqual(expectedText); + }); +}); diff --git a/tests/unit/NoteCardScheduleParser.test.ts b/tests/unit/NoteCardScheduleParser.test.ts new file mode 100644 index 00000000..49f37df4 --- /dev/null +++ b/tests/unit/NoteCardScheduleParser.test.ts @@ -0,0 +1,43 @@ +import { CardScheduleInfo, NoteCardScheduleParser } from "src/CardSchedule"; +import { TICKS_PER_DAY } from "src/constants"; +import { setupStaticDateProvider_20230906 } from "src/util/DateProvider"; + +beforeAll(() => { + setupStaticDateProvider_20230906(); +}); + +test("No schedule info for question", () => { + expect(NoteCardScheduleParser.createCardScheduleInfoList("A::B")).toEqual([]); +}); + +test("Single schedule info for question (on separate line)", () => { + let actual: CardScheduleInfo[] = + NoteCardScheduleParser.createCardScheduleInfoList(`What symbol represents an electric field:: $\\large \\vec E$ +`); + + expect(actual).toEqual([ + CardScheduleInfo.fromDueDateStr("2023-09-02", 4, 270, -4 * TICKS_PER_DAY), + ]); +}); + +test("Single schedule info for question (on same line)", () => { + let actual: CardScheduleInfo[] = NoteCardScheduleParser.createCardScheduleInfoList( + `What symbol represents an electric field:: $\\large \\vec E$`, + ); + + expect(actual).toEqual([ + CardScheduleInfo.fromDueDateStr("2023-09-02", 4, 270, -4 * TICKS_PER_DAY), + ]); +}); + +test("Multiple schedule info for question (on separate line)", () => { + let actual: CardScheduleInfo[] = + NoteCardScheduleParser.createCardScheduleInfoList(`This is a really very ==interesting== and ==fascinating== and ==great== test + `); + + expect(actual).toEqual([ + CardScheduleInfo.fromDueDateStr("2023-09-03", 1, 230, -3 * TICKS_PER_DAY), + CardScheduleInfo.fromDueDateStr("2023-09-05", 3, 250, -1 * TICKS_PER_DAY), + CardScheduleInfo.fromDueDateStr("2023-09-06", 4, 270, 0), + ]); +}); diff --git a/tests/unit/NoteEaseList.test.ts b/tests/unit/NoteEaseList.test.ts new file mode 100644 index 00000000..4f5c4b81 --- /dev/null +++ b/tests/unit/NoteEaseList.test.ts @@ -0,0 +1,22 @@ +import { NoteEaseList } from "src/NoteEaseList"; +import { DEFAULT_SETTINGS } from "src/settings"; + +test("baseEase", async () => { + let list: NoteEaseList = new NoteEaseList(DEFAULT_SETTINGS); + expect(list.baseEase).toEqual(250); +}); + +test("hasEaseForPath", async () => { + let list: NoteEaseList = new NoteEaseList(DEFAULT_SETTINGS); + expect(list.hasEaseForPath("Unknown path")).toEqual(false); + + list.setEaseForPath("Known path", 100); + expect(list.hasEaseForPath("Known path")).toEqual(true); +}); + +test("getEaseByPath", async () => { + let list: NoteEaseList = new NoteEaseList(DEFAULT_SETTINGS); + + list.setEaseForPath("Known path", 100); + expect(list.getEaseByPath("Known path")).toEqual(100); +}); diff --git a/tests/unit/NoteFileLoader.test.ts b/tests/unit/NoteFileLoader.test.ts new file mode 100644 index 00000000..ab07ae5f --- /dev/null +++ b/tests/unit/NoteFileLoader.test.ts @@ -0,0 +1,35 @@ +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"; + +var noteFileLoader: NoteFileLoader = new NoteFileLoader(DEFAULT_SETTINGS); + +describe("load", () => { + test("Multiple questions, none with too many schedule details", async () => { + let noteText: string = `#flashcards/test +Q1::A1 +#flashcards Q2::A2 + +Q3:::A3 + +`; + let file: UnitTestSRFile = new UnitTestSRFile(noteText); + let note: Note = await noteFileLoader.load(file, TopicPath.emptyPath); + expect(note.hasChanged).toEqual(false); + }); + + test("Multiple questions, some with too many schedule details", async () => { + let noteText: string = `#flashcards/test +Q1::A1 +#flashcards Q2::A2 + +Q3:::A3 + +`; + let file: UnitTestSRFile = new UnitTestSRFile(noteText); + let note: Note = await noteFileLoader.load(file, TopicPath.emptyPath); + expect(note.hasChanged).toEqual(true); + }); +}); diff --git a/tests/unit/NoteParser.test.ts b/tests/unit/NoteParser.test.ts new file mode 100644 index 00000000..fb57ed00 --- /dev/null +++ b/tests/unit/NoteParser.test.ts @@ -0,0 +1,28 @@ +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"; + +let parser: NoteParser = new NoteParser(DEFAULT_SETTINGS); + +beforeAll(() => { + setupStaticDateProvider_20230906(); +}); + +describe("Multiple questions in the text", () => { + test("SingleLineBasic: No schedule info", async () => { + let noteText: string = `#flashcards/test +Q1::A1 +Q2::A2 +Q3::A3 +`; + let file: UnitTestSRFile = new UnitTestSRFile(noteText); + let folderTopicPath = TopicPath.emptyPath; + let note: Note = await parser.parse(file, folderTopicPath); + let questionList = note.questionList; + expect(questionList.length).toEqual(3); + }); +}); diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts new file mode 100644 index 00000000..323f9cbb --- /dev/null +++ b/tests/unit/NoteQuestionParser.test.ts @@ -0,0 +1,352 @@ +import { NoteQuestionParser } from "src/NoteQuestionParser"; +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 { createTest_NoteQuestionParser } from "./SampleItems"; +import { ISRFile, UnitTestSRFile } from "src/SRFile"; +import { setupStaticDateProvider_20230906 } from "src/util/DateProvider"; + +let parserWithDefaultSettings: NoteQuestionParser = createTest_NoteQuestionParser(DEFAULT_SETTINGS); +let settings_ConvertFoldersToDecks: SRSettings = { ...DEFAULT_SETTINGS }; +settings_ConvertFoldersToDecks.convertFoldersToDecks = true; +let parser_ConvertFoldersToDecks: NoteQuestionParser = createTest_NoteQuestionParser( + settings_ConvertFoldersToDecks, +); + +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); + + expect(await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath)).toEqual( + [], + ); +}); + +describe("Single question in the text", () => { + test("SingleLineBasic: No schedule info", async () => { + let noteText: string = ` +A::B +`; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let card1 = { + cardIdx: 0, + scheduleInfo: null as CardScheduleInfo, + }; + let expected = [ + { + questionType: CardType.SingleLineBasic, + topicPath: TopicPath.emptyPath, + questionText: { + original: `A::B`, + actualQuestion: "A::B", + topicPath: TopicPath.emptyPath, + }, + + lineNo: 1, + hasEditLaterTag: false, + cards: [card1], + hasChanged: false, + }, + ]; + expect( + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath), + ).toMatchObject(expected); + }); + + test("SingleLineBasic: With schedule info", async () => { + let noteText: string = `#flashcards/test +A::B + + `; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let delayDays = 3 - 6; + let card1 = { + cardIdx: 0, + scheduleInfo: CardScheduleInfo.fromDueDateStr( + "2023-09-03", + 1, + 230, + delayDays * TICKS_PER_DAY, + ), + }; + let expected = [ + { + questionType: CardType.SingleLineBasic, + topicPath: new TopicPath(["flashcards", "test"]), + questionText: { + original: `A::B +`, + actualQuestion: "A::B", + topicPath: TopicPath.emptyPath, + textHash: "1c6b0b01215dc4", + }, + lineNo: 1, + hasEditLaterTag: false, + cards: [card1], + hasChanged: false, + }, + ]; + expect( + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath), + ).toMatchObject(expected); + }); +}); + +describe("Multiple questions in the text", () => { + test("SingleLineBasic: No schedule info", async () => { + let noteText: string = `#flashcards/test +Q1::A1 +Q2::A2 +`; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let questionList: Question[] = await parser_ConvertFoldersToDecks.createQuestionList( + noteFile, + folderTopicPath, + ); + expect(questionList.length).toEqual(2); + }); + + test("SingleLineBasic: Note topic applies to all questions when not overriden", async () => { + let noteText: string = ` +Q1::A1 +Q2::A2 +Q3::A3 +`; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let folderTopicPath: TopicPath = new TopicPath(["flashcards", "science"]); + let questionList: Question[] = await parser_ConvertFoldersToDecks.createQuestionList( + noteFile, + folderTopicPath, + ); + 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"])); + }); +}); + +describe("Handling tags within note", () => { + describe("Settings mode: Convert folder path to tag", () => { + let settings: SRSettings = { ...DEFAULT_SETTINGS }; + settings.convertFoldersToDecks = true; + let parser2: NoteQuestionParser = createTest_NoteQuestionParser(settings); + + test("Folder path applies to all questions within note", async () => { + let noteText: string = ` + Q1::A1 + Q2::A2 + Q3::A3 + `; + + let noteFile: ISRFile = new UnitTestSRFile(noteText); + let folderTopicPath: TopicPath = new TopicPath(["folder", "subfolder"]); + let questionList: Question[] = await parser2.createQuestionList( + noteFile, + folderTopicPath, + ); + expect(questionList.length).toEqual(3); + for (let i = 0; i < questionList.length; i++) + expect(questionList[i].topicPath).toEqual(new TopicPath(["folder", "subfolder"])); + }); + + test("Topic tag within note is ignored (outside all questions)", async () => { + let noteText: string = `#flashcards/test +Q1::A1 + `; + + let noteFile: ISRFile = new UnitTestSRFile(noteText); + let folderTopicPath: TopicPath = new TopicPath(["folder", "subfolder"]); + let questionList: Question[] = await parser2.createQuestionList( + noteFile, + folderTopicPath, + ); + expect(questionList.length).toEqual(1); + expect(questionList[0].topicPath).toEqual(new TopicPath(["folder", "subfolder"])); + }); + + // Behavior here mimics SR_ORIGINAL + // It could be argued that topic tags within a question should override the folder based topic + test("Topic tag within note is ignored (within specific question)", async () => { + // The tag "#flashcards/test" specifies a different topic than the folderTopicPath below + let noteText: string = ` +#flashcards/test Q1::A1 + `; + + let noteFile: ISRFile = new UnitTestSRFile(noteText); + let folderTopicPath: TopicPath = new TopicPath(["folder", "subfolder"]); + let questionList: Question[] = await parser2.createQuestionList( + noteFile, + folderTopicPath, + ); + expect(questionList.length).toEqual(1); + expect(questionList[0].topicPath).toEqual(new TopicPath(["folder", "subfolder"])); + }); + }); + + describe("Settings mode: Use tags within note", () => { + expect(parserWithDefaultSettings.settings.convertFoldersToDecks).toEqual(false); + + test("Topic tag before first question applies to all questions", async () => { + let noteText: string = `#flashcards/test + Q1::A1 + Q2::A2 + Q3::A3 + `; + 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, + ); + expect(questionList.length).toEqual(3); + expect(questionList[0].topicPath).toEqual(expectedPath); + expect(questionList[1].topicPath).toEqual(expectedPath); + expect(questionList[2].topicPath).toEqual(expectedPath); + }); + + test("Topic tag within question overrides the note topic, for that topic only", async () => { + let noteText: string = `#flashcards/test + Q1::A1 + #flashcards/examination Q2::A2 + Q3::This has the "flashcards/test" topic, not "flashcards/examination" + `; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( + noteFile, + folderTopicPath, + ); + 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"])); + }); + + 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 () => { + let noteText: string = ` + Q1::A1 This has the "flashcards/test" topic, even though the first topic tag is after this line in the file + #flashcards/test + Q2::A2 + Q3::A3 + `; + 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, + ); + expect(questionList.length).toEqual(3); + for (let i = 0; i < questionList.length; i++) + expect(questionList[i].topicPath).toEqual(expectedPath); + }); + + test("Only first topic tag within note (outside questions) is used as the note's topic tag, subsequent ignored", async () => { + let noteText: string = ` + Q1::A1 + #flashcards/test + Q2::A2 + #flashcards/examination + Q3::This has the "flashcards/test" topic, not "flashcards/examination" + `; + 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, + ); + expect(questionList.length).toEqual(3); + for (let i = 0; i < questionList.length; i++) + expect(questionList[i].topicPath).toEqual(expectedPath); + }); + }); + + describe("Tags within question", () => { + expect(parserWithDefaultSettings.settings.convertFoldersToDecks).toEqual(false); + + test("Leading white space before topic tag", async () => { + let noteText: string = ` + #flashcards/science Q5::A5 + `; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let expectedPath: TopicPath = new TopicPath(["flashcards", "science"]); + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( + noteFile, + folderTopicPath, + ); + expect(questionList.length).toEqual(1); + expect(questionList[0].topicPath).toEqual(expectedPath); + expect(questionList[0].cards.length).toEqual(1); + expect(questionList[0].cards[0].front).toEqual("Q5"); + }); + }); +}); + +function checkQuestion1(question: Question) { + expect(question.cards.length).toEqual(1); + let card1 = { + cardIdx: 0, + isDue: false, + front: "Q1", + back: "A1", + scheduleInfo: null as CardScheduleInfo, + }; + let expected = { + questionType: CardType.SingleLineBasic, + topicPath: TopicPath.emptyPath, + questionTextOriginal: `Q1::A1`, + questionTextCleaned: "Q1::A1", + lineNo: 1, + hasEditLaterTag: false, + context: "", + hasChanged: false, + }; + expect(question).toMatchObject(expected); + expect(question.cards[0]).toMatchObject(card1); + return question; +} + +function checkQuestion2(question: Question) { + expect(question.cards.length).toEqual(1); + let card1 = { + cardIdx: 0, + isDue: false, + front: "Q2", + back: "A2", + scheduleInfo: null as CardScheduleInfo, + }; + let expected = { + questionType: CardType.SingleLineBasic, + topicPath: TopicPath.emptyPath, + questionTextOriginal: `Q2::A2`, + questionTextCleaned: "Q2::A2", + lineNo: 2, + hasEditLaterTag: false, + context: "", + hasChanged: false, + }; + expect(question).toMatchObject(expected); + expect(question.cards[0]).toMatchObject(card1); + return question; +} diff --git a/tests/unit/Question.test.ts b/tests/unit/Question.test.ts new file mode 100644 index 00000000..d61c4668 --- /dev/null +++ b/tests/unit/Question.test.ts @@ -0,0 +1,34 @@ +import { TopicPath } from "src/TopicPath"; +import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; +import { Question, QuestionText } from "src/Question"; + +let settings_cardCommentOnSameLine: SRSettings = { ...DEFAULT_SETTINGS }; +settings_cardCommentOnSameLine.cardCommentOnSameLine = true; + +describe("Question", () => { + describe("getHtmlCommentSeparator", () => { + test("Ends with a code block", async () => { + let text: string = + "How do you ... Python?\n?\n" + + "```\nprint('Hello World!')\nprint('Howdy?')\nlambda x: x[0]\n```"; + + let question: Question = new Question({ + questionText: new QuestionText(text, TopicPath.emptyPath, "", text), + }); + + expect(question.getHtmlCommentSeparator(DEFAULT_SETTINGS)).toEqual("\n"); + expect(question.getHtmlCommentSeparator(settings_cardCommentOnSameLine)).toEqual("\n"); + }); + + test("Doesn't end with a code block", async () => { + let text: string = "Q1::A1"; + + let question: Question = new Question({ + questionText: new QuestionText(text, TopicPath.emptyPath, "", text), + }); + + expect(question.getHtmlCommentSeparator(DEFAULT_SETTINGS)).toEqual("\n"); + expect(question.getHtmlCommentSeparator(settings_cardCommentOnSameLine)).toEqual(" "); + }); + }); +}); diff --git a/tests/unit/QuestionType.test.ts b/tests/unit/QuestionType.test.ts new file mode 100644 index 00000000..108c153b --- /dev/null +++ b/tests/unit/QuestionType.test.ts @@ -0,0 +1,98 @@ +import { CardType } from "src/Question"; +import { CardFrontBack, CardFrontBackUtil, QuestionType_ClozeUtil } from "src/QuestionType"; +import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; + +test("CardType.SingleLineBasic", () => { + expect(CardFrontBackUtil.expand(CardType.SingleLineBasic, "A::B", DEFAULT_SETTINGS)).toEqual([ + new CardFrontBack("A", "B"), + ]); +}); + +test("CardType.SingleLineReversed", () => { + expect( + CardFrontBackUtil.expand(CardType.SingleLineReversed, "A:::B", DEFAULT_SETTINGS), + ).toEqual([new CardFrontBack("A", "B"), new CardFrontBack("B", "A")]); +}); + +describe("CardType.MultiLineBasic", () => { + test("Basic", () => { + expect( + CardFrontBackUtil.expand( + CardType.MultiLineBasic, + "A1\nA2\n?\nB1\nB2", + DEFAULT_SETTINGS, + ), + ).toEqual([new CardFrontBack("A1\nA2", "B1\nB2")]); + }); +}); + +test("CardType.MultiLineReversed", () => { + expect( + CardFrontBackUtil.expand( + CardType.MultiLineReversed, + "A1\nA2\n??\nB1\nB2", + DEFAULT_SETTINGS, + ), + ).toEqual([new CardFrontBack("A1\nA2", "B1\nB2"), new CardFrontBack("B1\nB2", "A1\nA2")]); +}); + +test("CardType.Cloze", () => { + let frontHtml = QuestionType_ClozeUtil.renderClozeFront(); + + expect( + CardFrontBackUtil.expand( + CardType.Cloze, + "This is a very ==interesting== test", + DEFAULT_SETTINGS, + ), + ).toEqual([ + new CardFrontBack( + "This is a very " + frontHtml + " test", + "This is a very " + QuestionType_ClozeUtil.renderClozeBack("interesting") + " test", + ), + ]); + + let settings2: SRSettings = DEFAULT_SETTINGS; + settings2.convertBoldTextToClozes = true; + settings2.convertHighlightsToClozes = true; + settings2.convertCurlyBracketsToClozes = true; + + expect( + CardFrontBackUtil.expand(CardType.Cloze, "This is a very **interesting** test", settings2), + ).toEqual([ + new CardFrontBack( + "This is a very " + frontHtml + " test", + "This is a very " + QuestionType_ClozeUtil.renderClozeBack("interesting") + " test", + ), + ]); + + expect( + CardFrontBackUtil.expand(CardType.Cloze, "This is a very {{interesting}} test", settings2), + ).toEqual([ + new CardFrontBack( + "This is a very " + frontHtml + " test", + "This is a very " + QuestionType_ClozeUtil.renderClozeBack("interesting") + " test", + ), + ]); + + expect( + CardFrontBackUtil.expand( + CardType.Cloze, + "This is a really very {{interesting}} and ==fascinating== and **great** test", + settings2, + ), + ).toEqual([ + new CardFrontBack( + "This is a really very [...] and fascinating and great test", + "This is a really very interesting and fascinating and great test", + ), + new CardFrontBack( + "This is a really very interesting and [...] and great test", + "This is a really very interesting and fascinating and great test", + ), + new CardFrontBack( + "This is a really very interesting and fascinating and [...] test", + "This is a really very interesting and fascinating and great test", + ), + ]); +}); diff --git a/tests/unit/SampleItems.ts b/tests/unit/SampleItems.ts new file mode 100644 index 00000000..8c5434fe --- /dev/null +++ b/tests/unit/SampleItems.ts @@ -0,0 +1,57 @@ +import { Card } from "src/Card"; +import { Deck } from "src/Deck"; +import { Note } from "src/Note"; +import { NoteParser } from "src/NoteParser"; +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"; + +export function createTest_NoteQuestionParser(settings: SRSettings): NoteQuestionParser { + let questionParser: NoteQuestionParser = new NoteQuestionParser(settings); + return questionParser; +} +export function createTest_NoteParser(): NoteParser { + let result = new NoteParser(DEFAULT_SETTINGS); + return result; +} +export const test_RefDate_20230906: Date = new Date(2023, 8, 6); + +export class SampleItemDecks { + static async createSingleLevelTree_NewCards(): Promise { + let text: string = ` +Q1::A1 +Q2::A2 +Q3::A3`; + return await SampleItemDecks.createDeckFromText(text, new TopicPath(["flashcards"])); + } + + static createScienceTree(): Deck { + let deck: Deck = new Deck("Root", null); + deck.getOrCreateDeck(new TopicPath(["Science", "Physics", "Electromagnetism"])); + deck.getOrCreateDeck(new TopicPath(["Science", "Physics", "Light"])); + deck.getOrCreateDeck(new TopicPath(["Science", "Physics", "Fluids"])); + deck.getOrCreateDeck(new TopicPath(["Math", "Geometry"])); + deck.getOrCreateDeck(new TopicPath(["Math", "Algebra", "Polynomials"])); + return deck; + } + + static async createDeckFromText(text: string, folderTopicPath: TopicPath): Promise { + let file: UnitTestSRFile = new UnitTestSRFile(text); + return await this.createDeckFromFile(file, folderTopicPath); + } + + static async createDeckFromFile( + file: UnitTestSRFile, + folderTopicPath: TopicPath, + ): 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); + return deck; + } +} diff --git a/tests/unit/TopicPath.test.ts b/tests/unit/TopicPath.test.ts new file mode 100644 index 00000000..ba77f027 --- /dev/null +++ b/tests/unit/TopicPath.test.ts @@ -0,0 +1,335 @@ +import { ISRFile, UnitTestSRFile } from "src/SRFile"; +import { TopicPath } from "src/TopicPath"; +import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; + +describe("Constructor exception handling", () => { + test("Constructor rejects null path", () => { + const t = () => { + let path: TopicPath = new TopicPath(null); + }; + expect(t).toThrow(); + }); + + test("Constructor allows zero length array", () => { + let path: TopicPath = new TopicPath([]); + expect(path.hasPath).toEqual(false); + }); + + test("Constructor rejects path that includes '/'", () => { + const t = () => { + let path: TopicPath = new TopicPath(["Hello/Goodbye"]); + }; + expect(t).toThrow(); + }); +}); + +describe("shift", () => { + test("shift() on multi-part path", () => { + let path: TopicPath = new TopicPath(["Level1", "Level2", "Level3"]); + let result: string = path.shift(); + + expect(result).toEqual("Level1"); + expect(path).toEqual(new TopicPath(["Level2", "Level3"])); + }); + + test("shift() on single-part path", () => { + let path: TopicPath = new TopicPath(["Level1"]); + let result: string = path.shift(); + + expect(result).toEqual("Level1"); + expect(path.hasPath).toEqual(false); + }); + + test("shift() on empty path", () => { + let path: TopicPath = new TopicPath(["Level1"]); + let result: string = path.shift(); + + const t = () => { + path.shift(); + }; + expect(t).toThrow("can't shift an empty path"); + }); +}); + +describe("getTopicPathFromCardText", () => { + test("Card text doesn't include tag", () => { + let cardText: string = "Card text doesn't include tag"; + let path: TopicPath = TopicPath.getTopicPathFromCardText(cardText); + + expect(path).toEqual(null); + }); + + test("Card text includes single level tag", () => { + let cardText: string = "#flashcards Card text does include tag"; + let path: TopicPath = TopicPath.getTopicPathFromCardText(cardText); + + expect(path).toEqual(new TopicPath(["flashcards"])); + }); + + test("Card text includes multi level tag", () => { + let cardText: string = "#flashcards/science/chemistry Card text does include tag"; + let path: TopicPath = TopicPath.getTopicPathFromCardText(cardText); + + expect(path).toEqual(new TopicPath(["flashcards", "science", "chemistry"])); + + cardText = "#flashcards/examination Q2::A2"; + path = TopicPath.getTopicPathFromCardText(cardText); + + expect(path).toEqual(new TopicPath(["flashcards", "examination"])); + }); + + test("Card text includes 2 multi level tags", () => { + let cardText: string = + "#flashcards/science/chemistry Card text includes multiple tag #flashcards/test/chemistry"; + let path: TopicPath = TopicPath.getTopicPathFromCardText(cardText); + + expect(path).toEqual(new TopicPath(["flashcards", "science", "chemistry"])); + }); +}); + +describe("removeTopicPathFromCardText", () => { + test("Card text doesn't include tag", () => { + let cardText: string = "Card text doesn't include tag"; + let expectedCardText: string = cardText; + let [actualQuestion, whiteSpace] = TopicPath.removeTopicPathFromStartOfCardText(cardText); + + expect(actualQuestion).toEqual(expectedCardText); + expect(whiteSpace).toEqual(""); + }); + + test("Card text includes single level tag", () => { + let cardText: string = "#flashcards Card text does include tag"; + let [actualQuestion, whiteSpace] = TopicPath.removeTopicPathFromStartOfCardText(cardText); + + expect(actualQuestion).toEqual("Card text does include tag"); + expect(whiteSpace).toEqual(" "); + }); + + test("Card text includes multi level tag", () => { + let cardText: string = "#flashcards/science/chemistry Card text does include tag"; + let [actualQuestion, whiteSpace] = TopicPath.removeTopicPathFromStartOfCardText(cardText); + + expect(actualQuestion).toEqual("Card text does include tag"); + expect(whiteSpace).toEqual(" "); + }); + + test("White space present before topic tag", () => { + let cardText: string = " #flashcards/science/chemistry Card text does include tag"; + let [actualQuestion, whiteSpace] = TopicPath.removeTopicPathFromStartOfCardText(cardText); + + expect(actualQuestion).toEqual("Card text does include tag"); + expect(whiteSpace).toEqual(" "); + }); + + test("Multiple spaces after topic tag", () => { + let spaces: string = " "; + let cardText: string = `#flashcards/science/chemistry${spaces}Card text does include tag`; + let [actualQuestion, whiteSpace] = TopicPath.removeTopicPathFromStartOfCardText(cardText); + + expect(actualQuestion).toEqual("Card text does include tag"); + expect(whiteSpace).toEqual(spaces); + }); +}); + +describe("getTopicPathFromTag", () => { + test("Null string", () => { + const t = () => { + TopicPath.getTopicPathFromTag(null); + }; + expect(t).toThrow(); + }); + + test("Empty string", () => { + const t = () => { + TopicPath.getTopicPathFromTag(""); + }; + expect(t).toThrow(); + }); + + test("String that doesn't start with a #", () => { + const t = () => { + TopicPath.getTopicPathFromTag("Invalid tag"); + }; + expect(t).toThrow(); + }); + + test("String that is only the #", () => { + const t = () => { + TopicPath.getTopicPathFromTag("#"); + }; + expect(t).toThrow(); + }); + + test("Single level tag", () => { + let result: TopicPath = TopicPath.getTopicPathFromTag("#flashcard"); + + expect(result.path).toEqual(["flashcard"]); + }); + + test("Multi level tag", () => { + let result: TopicPath = TopicPath.getTopicPathFromTag("#flashcard/science/physics"); + + expect(result.path).toEqual(["flashcard", "science", "physics"]); + }); + + test("Tag with trailing slash", () => { + let result: TopicPath = TopicPath.getTopicPathFromTag("#flashcard/science/physics/"); + + expect(result.path).toEqual(["flashcard", "science", "physics"]); + }); + + test("Tag with multiple adjacent slashes", () => { + let result: TopicPath = TopicPath.getTopicPathFromTag("#flashcard///science//physics"); + + expect(result.path).toEqual(["flashcard", "science", "physics"]); + }); +}); + +describe("isSameOrAncestorOf", () => { + test("a, b are both empty", () => { + let a: TopicPath = TopicPath.emptyPath; + let b: TopicPath = TopicPath.emptyPath; + expect(a.isSameOrAncestorOf(b)).toEqual(true); + }); + + test("a is empty, b has path", () => { + let a: TopicPath = TopicPath.emptyPath; + let b: TopicPath = new TopicPath(["flashcard"]); + expect(a.isSameOrAncestorOf(b)).toEqual(false); + }); + + test("a has path, b is empty", () => { + let a: TopicPath = new TopicPath(["flashcard"]); + let b: TopicPath = TopicPath.emptyPath; + expect(a.isSameOrAncestorOf(b)).toEqual(false); + }); + + describe("a, b both have paths", () => { + test("a same as b", () => { + let a: TopicPath = new TopicPath(["flashcard"]); + let b: TopicPath = new TopicPath(["flashcard"]); + expect(a.isSameOrAncestorOf(b)).toEqual(true); + + a = new TopicPath(["flashcard", "science"]); + b = new TopicPath(["flashcard", "science"]); + expect(a.isSameOrAncestorOf(b)).toEqual(true); + }); + + test("a is ancestor of b", () => { + let a: TopicPath = new TopicPath(["flashcard"]); + let b: TopicPath = new TopicPath(["flashcard", "science"]); + expect(a.isSameOrAncestorOf(b)).toEqual(true); + + a = new TopicPath(["flashcard"]); + b = new TopicPath(["flashcard", "science", "physics"]); + expect(a.isSameOrAncestorOf(b)).toEqual(true); + }); + + test("a is different to b", () => { + let a: TopicPath = new TopicPath(["flashcard", "math"]); + let b: TopicPath = new TopicPath(["flashcard", "science"]); + expect(a.isSameOrAncestorOf(b)).toEqual(false); + + a = new TopicPath(["flashcard", "science", "physics"]); + b = new TopicPath(["flashcard", "science", "chemistry"]); + expect(a.isSameOrAncestorOf(b)).toEqual(false); + }); + }); +}); + +describe("clone", () => { + test("clone of empty", () => { + let a: TopicPath = TopicPath.emptyPath; + let b: TopicPath = a.clone(); + expect(b.isEmptyPath).toEqual(true); + }); + + test("clone of path", () => { + let a: TopicPath = new TopicPath(["flashcard"]); + let b: TopicPath = a.clone(); + expect(b.path).toEqual(["flashcard"]); + + a = new TopicPath(["flashcard", "science"]); + b = a.clone(); + expect(b.path).toEqual(["flashcard", "science"]); + }); +}); + +describe("formatTag", () => { + test("Simple test", () => { + let topicPath: TopicPath = new TopicPath(["flashcards", "science"]); + + expect(topicPath.formatAsTag()).toEqual("#flashcards/science"); + }); + + test("Empty path", () => { + const t = () => { + let str: string = TopicPath.emptyPath.formatAsTag(); + }; + expect(t).toThrow(); + }); +}); + +describe("isValidTag", () => { + test("Invalid tags", () => { + expect(TopicPath.isValidTag(null)).toEqual(false); + expect(TopicPath.isValidTag("")).toEqual(false); + expect(TopicPath.isValidTag("!Flashcards")).toEqual(false); + expect(TopicPath.isValidTag("#")).toEqual(false); + }); + + test("Valid tags", () => { + 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 new file mode 100644 index 00000000..092f0423 --- /dev/null +++ b/tests/unit/deck.test.ts @@ -0,0 +1,286 @@ +import { CardListType, Deck } from "src/Deck"; +import { TopicPath } from "src/TopicPath"; +import { SampleItemDecks } from "./SampleItems"; +import { Card } from "src/Card"; + +describe("constructor", () => { + test("Deck name", () => { + let actual: Deck = new Deck("Great Name", null); + + expect(actual.deckName).toEqual("Great Name"); + }); +}); + +describe("getOrCreateDeck()", () => { + test("Empty topic path", () => { + let deck: Deck = new Deck("Great Name", null); + deck.getOrCreateDeck(TopicPath.emptyPath); + + expect(deck.deckName).toEqual("Great Name"); + expect(deck.subdecks.length).toEqual(0); + }); + + test("Create single subdeck on empty deck", () => { + let deck: Deck = new Deck("Root", null); + let path: TopicPath = new TopicPath(["Level1"]); + let subdeck: Deck = deck.getOrCreateDeck(path); + + expect(deck.deckName).toEqual("Root"); + expect(deck.subdecks.length).toEqual(1); + expect(subdeck === deck.subdecks[0]).toEqual(true); + expect(subdeck.deckName).toEqual("Level1"); + }); + + test("Create multiple subdecks under single deck", () => { + let deck: Deck = new Deck("Root", null); + let subdeckA: Deck = deck.getOrCreateDeck(new TopicPath(["Level1A"])); + let subdeckB: Deck = deck.getOrCreateDeck(new TopicPath(["Level1B"])); + let subdeckC: Deck = deck.getOrCreateDeck(new TopicPath(["Level1C"])); + + expect(deck.deckName).toEqual("Root"); + expect(deck.subdecks.length).toEqual(3); + + expect(subdeckA === deck.subdecks[0]).toEqual(true); + expect(subdeckA.deckName).toEqual("Level1A"); + + expect(subdeckB === deck.subdecks[1]).toEqual(true); + expect(subdeckB.deckName).toEqual("Level1B"); + + expect(subdeckC === deck.subdecks[2]).toEqual(true); + expect(subdeckC.deckName).toEqual("Level1C"); + }); + + test("Create multi-level deck in separate steps", () => { + let deck: Deck = new Deck("Root", null); + let subdeck1: Deck = deck.getOrCreateDeck(new TopicPath(["Level1"])); + let subdeck2: Deck = subdeck1.getOrCreateDeck(new TopicPath(["Level2"])); + let subdeck3: Deck = subdeck2.getOrCreateDeck(new TopicPath(["Level3"])); + + expect(deck.deckName).toEqual("Root"); + expect(deck.subdecks.length).toEqual(1); + + expect(subdeck1 === deck.subdecks[0]).toEqual(true); + expect(subdeck1.deckName).toEqual("Level1"); + expect(subdeck1.subdecks.length).toEqual(1); + + expect(subdeck2 === subdeck1.subdecks[0]).toEqual(true); + expect(subdeck2.deckName).toEqual("Level2"); + expect(subdeck2.subdecks.length).toEqual(1); + + expect(subdeck3 === subdeck2.subdecks[0]).toEqual(true); + expect(subdeck3.deckName).toEqual("Level3"); + expect(subdeck3.subdecks.length).toEqual(0); + }); + + test("Create multi-level deck in single step", () => { + let deck: Deck = new Deck("Root", null); + let subdeck3: Deck = deck.getOrCreateDeck(new TopicPath(["Level1", "Level2", "Level3"])); + + expect(deck.deckName).toEqual("Root"); + expect(deck.subdecks.length).toEqual(1); + + let subdeck1: Deck = deck.subdecks[0]; + expect(subdeck1.deckName).toEqual("Level1"); + expect(subdeck1.subdecks.length).toEqual(1); + + let subdeck2: Deck = subdeck1.subdecks[0]; + expect(subdeck2.deckName).toEqual("Level2"); + expect(subdeck2.subdecks.length).toEqual(1); + + expect(subdeck3 === subdeck2.subdecks[0]).toEqual(true); + expect(subdeck3.deckName).toEqual("Level3"); + expect(subdeck3.subdecks.length).toEqual(0); + }); +}); + +describe("getTopicPath()", () => { + test("Empty topic path", () => { + let deck: Deck = new Deck("Root", null); + let path: TopicPath = deck.getTopicPath(); + + expect(path.isEmptyPath).toEqual(true); + }); + + test("Single level topic path", () => { + let deck: Deck = new Deck("Root", null); + let subdeck: Deck = deck.getOrCreateDeck(new TopicPath(["Science"])); + let topicPath: TopicPath = subdeck.getTopicPath(); + + expect(topicPath.path).toEqual(["Science"]); + }); + + test("Multi level topic path", () => { + let deck: Deck = new Deck("Root", null); + let subdeck: Deck = deck.getOrCreateDeck(new TopicPath(["Science", "Chemistry"])); + let topicPath: TopicPath = subdeck.getTopicPath(); + + expect(topicPath.path).toEqual(["Science", "Chemistry"]); + }); +}); + +describe("appendCard()", () => { + test("Append to root deck", () => { + let deck: Deck = new Deck("Root", null); + let path: TopicPath = deck.getTopicPath(); + + expect(path.isEmptyPath).toEqual(true); + }); + + test("Single level topic path", () => { + let deck: Deck = new Deck("Root", null); + let subdeck: Deck = deck.getOrCreateDeck(new TopicPath(["Science"])); + let topicPath: TopicPath = subdeck.getTopicPath(); + + expect(topicPath.path).toEqual(["Science"]); + }); + + test("Multi level topic path", () => { + let deck: Deck = new Deck("Root", null); + let subdeck: Deck = deck.getOrCreateDeck(new TopicPath(["Science", "Chemistry"])); + let topicPath: TopicPath = subdeck.getTopicPath(); + + expect(topicPath.path).toEqual(["Science", "Chemistry"]); + }); +}); + +describe("toDeckArray()", () => { + test("Empty tree", () => { + let deckTree: Deck = new Deck("Root", null); + let deckArray: Deck[] = deckTree.toDeckArray(); + let nameArray: string[] = deckArray.map((deck) => deck.deckName); + + expect(nameArray).toEqual(["Root"]); + }); + + test("Single level test", () => { + let deckTree: Deck = new Deck("Root", null); + deckTree.getOrCreateDeck(new TopicPath(["Aliens"])); + let deckArray: Deck[] = deckTree.toDeckArray(); + let nameArray: string[] = deckArray.map((deck) => deck.deckName); + + let expectedArray: string[] = ["Root", "Aliens"]; + expect(nameArray).toEqual(expectedArray); + }); + + test("Multi level test", () => { + let deckTree: Deck = SampleItemDecks.createScienceTree(); + let deckArray: Deck[] = deckTree.toDeckArray(); + let nameArray: string[] = deckArray.map((deck) => deck.deckName); + + let expectedArray: string[] = [ + "Root", + "Science", + "Physics", + "Electromagnetism", + "Light", + "Fluids", + "Math", + "Geometry", + "Algebra", + "Polynomials", + ]; + expect(nameArray).toEqual(expectedArray); + }); +}); + +describe("copyWithCardFilter()", () => { + describe("Single level tree", () => { + test("No cards", () => { + let original: Deck = new Deck("Root", null); + let copy: Deck = original.copyWithCardFilter((card) => true); + + original.deckName = "New deck name"; + expect(copy.deckName).toEqual("Root"); + }); + + test("With new cards", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3`; + let original: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + let copy: Deck = original.copyWithCardFilter((card) => card.front.includes("2")); + + expect(copy.newFlashcards.length).toEqual(1); + expect(copy.newFlashcards[0].front).toEqual("Q2"); + }); + + test("With scheduled cards", async () => { + let text: string = ` + Q1::A1 + Q2::A2 + Q3::A3 `; + let original: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + let copy: Deck = original.copyWithCardFilter((card) => !card.front.includes("2")); + + expect(copy.newFlashcards.length).toEqual(0); + expect(copy.dueFlashcards.length).toEqual(2); + expect(copy.dueFlashcards[0].front).toEqual("Q1"); + expect(copy.dueFlashcards[1].front).toEqual("Q3"); + }); + }); + + describe("Multi level tree", () => { + test("No change in original deck after copy", async () => { + let text: string = ` + #flashcards Q1::A1 + #flashcards Q2::A2 + #flashcards Q3::A3 + + #flashcards/science Q4::A4 + #flashcards/science Q5::A5 + + #flashcards/science/physics Q6::A6`; + let original: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + let originalCountPreCopy: number = original.getCardCount(CardListType.All, true); + expect(originalCountPreCopy).toEqual(6); + + let copy: Deck = original.copyWithCardFilter( + (card) => parseInt(card.front[1]) % 2 == 1, + ); + let originalCountPostCopy: number = original.getCardCount(CardListType.All, true); + expect(originalCountPreCopy).toEqual(originalCountPostCopy); + }); + + test("With new cards", async () => { + let text: string = ` + #flashcards Q1::A1 + #flashcards Q2::A2 + #flashcards Q3::A3 + + #flashcards/science Q4::A4 + #flashcards/science Q5::A5 + + #flashcards/science/physics Q6::A6`; + let original: Deck = await SampleItemDecks.createDeckFromText( + text, + new TopicPath(["Root"]), + ); + + let copy: Deck = original.copyWithCardFilter( + (card) => parseInt(card.front[1]) % 2 == 1, + ); + + let subdeck: Deck = copy.getDeck(new TopicPath(["flashcards"])); + expect(subdeck.newFlashcards.length).toEqual(2); + expect(subdeck.newFlashcards[0].front).toEqual("Q1"); + expect(subdeck.newFlashcards[1].front).toEqual("Q3"); + + subdeck = copy.getDeck(new TopicPath(["flashcards", "science"])); + expect(subdeck.newFlashcards.length).toEqual(1); + expect(subdeck.newFlashcards[0].front).toEqual("Q5"); + + subdeck = copy.getDeck(new TopicPath(["flashcards", "science", "physics"])); + expect(subdeck.newFlashcards.length).toEqual(0); + }); + }); +}); diff --git a/tests/unit/parser.test.ts b/tests/unit/parser.test.ts index 7e687398..183dc679 100644 --- a/tests/unit/parser.test.ts +++ b/tests/unit/parser.test.ts @@ -1,5 +1,5 @@ import { parse } from "src/parser"; -import { CardType } from "src/scheduling"; +import { CardType } from "src/Question"; const defaultArgs: [string, string, string, string, boolean, boolean, boolean] = [ "::", @@ -28,6 +28,9 @@ test("Test parsing of single line basic cards", () => { [CardType.SingleLineBasic, "Q1::A1", 2], [CardType.SingleLineBasic, "Q2:: A2", 3], ]); + expect(parse("#flashcards/science Question ::Answer", ...defaultArgs)).toEqual([ + [CardType.SingleLineBasic, "#flashcards/science Question ::Answer", 0], + ]); }); test("Test parsing of single line reversed cards", () => { @@ -66,6 +69,9 @@ test("Test parsing of multi line basic cards", () => { [CardType.MultiLineBasic, "Line0\nQ1\n?\nA1\nAnswerExtra", 4], [CardType.MultiLineBasic, "Q2\n?\nA2", 9], ]); + expect(parse("#flashcards/tag-on-previous-line\nQuestion\n?\nAnswer", ...defaultArgs)).toEqual([ + [CardType.MultiLineBasic, "#flashcards/tag-on-previous-line\nQuestion\n?\nAnswer", 2], + ]); }); test("Test parsing of multi line reversed cards", () => { diff --git a/tests/unit/util/MultiLineTextFinder.test.ts b/tests/unit/util/MultiLineTextFinder.test.ts new file mode 100644 index 00000000..bec40d9f --- /dev/null +++ b/tests/unit/util/MultiLineTextFinder.test.ts @@ -0,0 +1,180 @@ +import { MultiLineTextFinder } from "src/util/MultiLineTextFinder"; +import { splitTextIntoLineArray } from "src/util/utils"; + +let space: string = " "; +let text10: string = `Some Stuff 0 More Stuff +Some Stuff 1 More Stuff +Some Stuff 2 More Stuff +Some Stuff 3 More Stuff +Some Stuff 4 More Stuff +Some Stuff 5 More Stuff +Some Stuff 6 More Stuff +Some Stuff 7 More Stuff +Some Stuff 8 More Stuff +Some Stuff 9 More Stuff`; +let text20: string = `Some Stuff 0 More Stuff +Some Stuff 1 More Stuff +Some Stuff 2 More Stuff +Some Stuff 3 More Stuff +Some Stuff 4 More Stuff +Some Stuff 5 More Stuff +Some Stuff 6 More Stuff +Some Stuff 7 More Stuff +Some Stuff 8 More Stuff +Some Stuff 9 More Stuff +Some Stuff 10 More Stuff +Some Stuff 11 More Stuff +Some Stuff 12 More Stuff +Some Stuff 13 More Stuff +Some Stuff 14 More Stuff +Some Stuff 15 More Stuff + Some Stuff 16 More Stuff +Some Stuff 17 More Stuff${space}${space} +Some Stuff 18 More Stuff +Some Stuff 19 More Stuff +Some Stuff 20 More Stuff +`; + +describe("find", () => { + describe("Single line search string - Match found", () => { + test("Search string present as complete line within text (identical)", () => { + let searchStr: string = "Some Stuff 14 More Stuff"; + + checkFindResult(text20, searchStr, 14); + }); + + test("Search string present as complete line within text (search has pre/post additional spaces)", () => { + let searchStr: string = " Some Stuff 14 More Stuff"; + checkFindResult(text20, searchStr, 14); + + searchStr = "Some Stuff 14 More Stuff "; + checkFindResult(text20, searchStr, 14); + + searchStr = " Some Stuff 14 More Stuff "; + checkFindResult(text20, searchStr, 14); + }); + + test("Search string present as complete line within text (source text has pre/post additional spaces)", () => { + let searchStr: string = "Some Stuff 16 More Stuff"; + checkFindResult(text20, searchStr, 16); + + searchStr = "Some Stuff 17 More Stuff"; + checkFindResult(text20, searchStr, 17); + }); + }); + + describe("Multi line search string - Match found", () => { + test("Search string present from line 1", () => { + let searchStr: string = `Some Stuff 1 More Stuff + Some Stuff 2 More Stuff + Some Stuff 3 More Stuff`; + + checkFindResult(text20, searchStr, 1); + }); + + test("Search string present mid file", () => { + let searchStr: string = `Some Stuff 9 More Stuff + Some Stuff 10 More Stuff + Some Stuff 11 More Stuff`; + checkFindResult(text20, searchStr, 9); + }); + + test("Search string present at end of file", () => { + let searchStr: string = `Some Stuff 19 More Stuff + Some Stuff 20 More Stuff`; + checkFindResult(text20, searchStr, 19); + }); + }); + + describe("Single line search string - No match found", () => { + test("Search string is a match but only to part of the line", () => { + let searchStr: string = "Stuff 14 More Stuff"; + + checkFindResult(text20, searchStr, null); + }); + }); + + describe("Multi line search string - No match found", () => { + test("Search string doesn't match any source line", () => { + let searchStr: string = `Nothing here that matches + Or hear `; + checkFindResult(text20, searchStr, null); + }); + + test("Some, but not all of the search string lines matches the source", () => { + let searchStr: string = `Some Stuff 9 More Stuff + Some Stuff 10 More Stuff + Some Stuff 11 More Stuff - this line doesn't match`; + checkFindResult(text20, searchStr, null); + }); + }); +}); + +describe("findAndReplace", () => { + test("Multi line search string present as exact match", () => { + let searchStr: string = `Some Stuff 4 More Stuff +Some Stuff 5 More Stuff +Some Stuff 6 More Stuff`; + + let replacementStr: string = `Replacement line`; + + let expectedResult: string = `Some Stuff 0 More Stuff +Some Stuff 1 More Stuff +Some Stuff 2 More Stuff +Some Stuff 3 More Stuff +Replacement line +Some Stuff 7 More Stuff +Some Stuff 8 More Stuff +Some Stuff 9 More Stuff`; + checkFindAndReplaceResult(text10, searchStr, replacementStr, expectedResult); + }); + + test("Multi line search string has pre/post spaces", () => { + let searchStr: string = `Some Stuff 4 More Stuff +${space}Some Stuff 5 More Stuff +Some Stuff 6 More Stuff${space}${space}`; + + let replacementStr: string = `Replacement line 1 +Replacement line 2`; + + let expectedResult: string = `Some Stuff 0 More Stuff +Some Stuff 1 More Stuff +Some Stuff 2 More Stuff +Some Stuff 3 More Stuff +Replacement line 1 +Replacement line 2 +Some Stuff 7 More Stuff +Some Stuff 8 More Stuff +Some Stuff 9 More Stuff`; + checkFindAndReplaceResult(text10, searchStr, replacementStr, expectedResult); + }); + + test("No match found", () => { + let searchStr: string = `Some Stuff 4 More Stuff +Some Stuff 5 More Stuff +Some Stuff 7 More Stuff`; + + let replacementStr: string = `Replacement line 1 +Replacement line 2`; + + let expectedResult: string = null; + checkFindAndReplaceResult(text10, searchStr, replacementStr, expectedResult); + }); +}); + +function checkFindAndReplaceResult( + text: string, + searchStr: string, + replacementStr: string, + expectedResult: string, +) { + let result: string = MultiLineTextFinder.findAndReplace(text, searchStr, replacementStr); + expect(result).toEqual(expectedResult); +} + +function checkFindResult(text: string, searchStr: string, expectedResult: number) { + let textArray = splitTextIntoLineArray(text); + let searchArray = splitTextIntoLineArray(searchStr); + let result: number = MultiLineTextFinder.find(textArray, searchArray); + expect(result).toEqual(expectedResult); +}