Skip to content

Commit

Permalink
Support multi-deck cards #495, and multiple decks in a single note #705
Browse files Browse the repository at this point in the history
… (#834)

* First bit of implementation

* Slight refactor of parse()

* Enhanced parser to return first and last line numbers

* Finished impl for enhanced parsing for multiple topics per note, updated existing test cases (haven't fixed main.js or added all needed test cases)

* Implementation & testing continuing

* Comment change

* pnpm format; updated changelog.md

* pnpm lint

* Need to rerun lint after format, and rerun format after lint

* Updated flashcard user documentation

* Added code to access the frontmatter tags

* Fixed test cases after merging with #815

* lint & format

* Fixed unit test case

* Minor

* Minor post merge fix

* Lint

* Fixed test case

---------

Co-authored-by: Stephen Mwangi <[email protected]>
  • Loading branch information
ronzulu and st3v3nmw authored Mar 9, 2024
1 parent a3fbe1d commit 77c4809
Show file tree
Hide file tree
Showing 33 changed files with 1,560 additions and 519 deletions.
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).

#### [1.11.2](https://github.com/st3v3nmw/obsidian-spaced-repetition/compare/1.11.1...1.11.2)

- Bug 495 Obsidian SRS ignores more than one tag https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/834
- Fixes [BUG] Malformed card text during review, when multi-line card has space on Q/A separator line https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/853
- Fixes bug #815 HTML review comment deactivates block identifier https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/815
- Implementation of some little UI improvements https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/869
- Add polish translation [`#889`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/889)
- Add support for Italian [`#886`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/886)
- update dependencies [`#892`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/892)
Expand Down
54 changes: 54 additions & 0 deletions docs/en/flashcards.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,60 @@ The green and blue counts on the right of each deck name represent due and new c

Note that `#flashcards` will match nested tags like `#flashcards/subdeck/subdeck`.

#### Multiple Tags Within a Single File

A single file can contain cards for multiple different decks.

This is possible because a tag pertains to all subsequent cards in a file until any subsequent tag.

For example:

```markdown
#flashcards/deckA
Question1 (in deckA)::Answer1
Question2 (also in deckA)::Answer2
Question3 (also in deckA)::Answer3

#flashcards/deckB
Question4 (in deckB)::Answer4
Question5 (also in deckB)::Answer5

#flashcards/deckC
Question6 (in deckC)::Answer6
```

#### A Single Card Within Multiple Decks

Usually the content of a card is only relevant to a single deck. However, sometimes content doesn't fall neatly into a single deck of the hierarchy.

In these cases, a card can be tagged as being part of multiple decks. The following card is specified as being in the three different decks listed.

```markdown
#flashcards/language/words #flashcards/trivia #flashcards/learned-from-tv
A group of cats is called a::clowder
```

Note that as shown in the above example, all tags must be placed on the same line, separated by spaces.

#### Question Specific Tags

A tag that is present at the start of the first line of a card is "question specific", and applies only to that card.

For example:

```markdown
#flashcards/deckA
Question1 (in deckA)::Answer1
Question2 (also in deckA)::Answer2
Question3 (also in deckA)::Answer3

#flashcards/deckB Question4 (in deckB)::Answer4

Question6 (in deckA)::Answer6
```

Here `Question6` will be part of `deckA` and not `deckB` as `deckB` is specific to `Question4` only.

### Using Folder Structure

The plugin will automatically search for folders that contain flashcards & use their paths to create decks & sub-decks i.e. `Folder/sub-folder/sub-sub-folder``Deck/sub-deck/sub-sub-deck`.
Expand Down
110 changes: 102 additions & 8 deletions src/Deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import { Card } from "./Card";
import { FlashcardReviewMode } from "./FlashcardReviewSequencer";
import { Question } from "./Question";
import { IQuestionPostponementList } from "./QuestionPostponementList";
import { TopicPath } from "./TopicPath";
import { TopicPath, TopicPathList } from "./TopicPath";

export enum CardListType {
NewCard,
DueCard,
All,
}

//
// The same card can be added to multiple decks e.g.
// #flashcards/language/words
// #flashcards/trivia
// To simplify certain functions (e.g. getDistinctCardCount), we explicitly use the same card object (and not a copy)
//
export class Deck {
public deckName: string;
public newFlashcards: Card[];
Expand Down Expand Up @@ -40,6 +46,41 @@ export class Deck {
return result;
}

public getDistinctCardCount(cardListType: CardListType, includeSubdeckCounts: boolean): number {
const cardList: Card[] = this.getFlattenedCardArray(cardListType, includeSubdeckCounts);

// The following selects distinct cards from cardList (based on reference equality)
const distinctCardSet = new Set(cardList);
// console.log(`getDistinctCardCount: ${this.deckName} ${distinctCardSet.size} ${this.getCardCount(cardListType, includeSubdeckCounts)}`);
return distinctCardSet.size;
}

public getFlattenedCardArray(
cardListType: CardListType,
includeSubdeckCounts: boolean,
): Card[] {
let result: Card[] = [] as Card[];
switch (cardListType) {
case CardListType.NewCard:
result = this.newFlashcards;
break;
case CardListType.DueCard:
result = this.dueFlashcards;
break;
case CardListType.All:
result = this.newFlashcards.concat(this.dueFlashcards);
}

if (includeSubdeckCounts) {
for (const subdeck of this.subdecks) {
result = result.concat(
subdeck.getFlattenedCardArray(cardListType, includeSubdeckCounts),
);
}
}
return result;
}

//
// Returns a count of the number of this question's cards are present in this deck.
// (The returned value would be <= question.cards.length)
Expand Down Expand Up @@ -67,6 +108,10 @@ export class Deck {
return this.parent == null;
}

getDeckByTopicTag(tag: string): Deck {
return this.getDeck(TopicPath.getTopicPathFromTag(tag));
}

getDeck(topicPath: TopicPath): Deck {
return this._getOrCreateDeck(topicPath, false);
}
Expand Down Expand Up @@ -100,6 +145,8 @@ export class Deck {
const list: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-this-alias
let deck: Deck = this;
// The root deck may have a dummy deck name, which we don't want
// So we first check that this isn't the root deck
while (!deck.isRootDeck) {
list.push(deck.deckName);
deck = deck.parent;
Expand All @@ -125,17 +172,64 @@ export class Deck {
return cardListType == CardListType.DueCard ? this.dueFlashcards : this.newFlashcards;
}

appendCard(topicPath: TopicPath, cardObj: Card): void {
appendCard(topicPathList: TopicPathList, cardObj: Card): void {
if (topicPathList.list.length == 0) {
this.appendCardToRootDeck(cardObj);
} else {
// We explicitly are adding the same card object to each of the specified decks
// This is required by getDistinctCardCount()
for (const topicPath of topicPathList.list) {
this.appendCard_SingleTopic(topicPath, cardObj);
}
}
}

appendCardToRootDeck(cardObj: Card): void {
this.appendCard_SingleTopic(TopicPath.emptyPath, cardObj);
}

appendCard_SingleTopic(topicPath: TopicPath, cardObj: Card): void {
const deck: Deck = this.getOrCreateDeck(topicPath);
const cardList: Card[] = deck.getCardListForCardType(cardObj.cardListType);

cardList.push(cardObj);
}

deleteCard(card: Card): void {
const cardList: Card[] = this.getCardListForCardType(card.cardListType);
const idx = cardList.indexOf(card);
if (idx != -1) cardList.splice(idx, 1);
//
// The question lists all the topics in which this card is included.
// The topics are relative to the base deck, and this method must be called on that deck
//
deleteQuestionFromAllDecks(question: Question, exceptionIfMissing: boolean): void {
for (const card of question.cards) {
this.deleteCardFromAllDecks(card, exceptionIfMissing);
}
}

deleteQuestion(question: Question, exceptionIfMissing: boolean): void {
for (const card of question.cards) {
this.deleteCardFromThisDeck(card, exceptionIfMissing);
}
}

//
// The card's question lists all the topics in which this card is included.
// The topics are relative to the base deck, and this method must be called on that deck
//
deleteCardFromAllDecks(card: Card, exceptionIfMissing: boolean): void {
for (const topicPath of card.question.topicPathList.list) {
const deck: Deck = this.getDeck(topicPath);
deck.deleteCardFromThisDeck(card, exceptionIfMissing);
}
}

deleteCardFromThisDeck(card: Card, exceptionIfMissing: boolean): void {
const newIdx = this.newFlashcards.indexOf(card);
if (newIdx != -1) this.newFlashcards.splice(newIdx, 1);
const dueIdx = this.dueFlashcards.indexOf(card);
if (dueIdx != -1) this.dueFlashcards.splice(dueIdx, 1);
if (newIdx == -1 && dueIdx == -1 && exceptionIfMissing) {
throw `deleteCardFromThisDeck: Card: ${card.front} not found in deck: ${this.deckName}`;
}
}

deleteCardAtIndex(index: number, cardListType: CardListType): void {
Expand Down Expand Up @@ -167,9 +261,9 @@ export class Deck {
}
}

debugLogToConsole(desc: string = null) {
debugLogToConsole(desc: string = null, indent: number = 0) {
let str: string = desc != null ? `${desc}: ` : "";
console.log((str += this.toString()));
console.log((str += this.toString(indent)));
}

toString(indent: number = 0): string {
Expand Down
Loading

0 comments on commit 77c4809

Please sign in to comment.