Skip to content

Commit

Permalink
Support RTL flashcards specified by frontmatter "direction" attribute (
Browse files Browse the repository at this point in the history
…#935)

* Nearly completed

* Added RTL support for flashcards edit modal

* Changes as part of the merge

* post upstream master merge fixes

* Minor code improvement

* lint and format

* Change log and documentation update

* Minor code change

* Fixed EditModal RTL

* lint and format

* Updated test cases to fix global coverage error

* Format & lint
  • Loading branch information
ronzulu authored Jul 24, 2024
1 parent 971e4af commit 3228e9c
Show file tree
Hide file tree
Showing 22 changed files with 324 additions and 47 deletions.
3 changes: 2 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ 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]
#### [Unreleased]

- RTL support https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/335
- Fixed notes selection when all notes are reviewed. [`#548`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/548)

#### [1.12.4](https://github.com/st3v3nmw/obsidian-spaced-repetition/compare/1.12.3...1.12.4)
Expand Down
18 changes: 18 additions & 0 deletions docs/en/flashcards.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,24 @@ The plugin will automatically search for folders that contain flashcards & use t

This is an alternative to the tagging option and can be enabled in settings.

## RTL Support

There are two ways that the plugin can be used with RTL languages, such as Arabic, Hebrew, Persian (Farsi).

If all cards are in a RTL language, then simply enable the global Obsidian option `Editor → Right-to-left (RTL)`.

If all cards within a single note have the same LTR/RTL direction, then frontmatter can be used to specify the text direction. For example:

```
---
direction: rtl
---
```

This is the same way text direction is specified to the `RTL Support` plugin.

Note that there is no current support for cards with different text directions within the same note.

## Reviewing

Once done creating cards, click on the flashcards button on the left ribbon to start reviewing the flashcards. After a card is reviewed, a HTML comment is added containing the next review day, the interval, and the card's ease.
Expand Down
8 changes: 7 additions & 1 deletion src/NoteFileLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Question } from "./Question";
import { TopicPath } from "./TopicPath";
import { NoteQuestionParser } from "./NoteQuestionParser";
import { SRSettings } from "./settings";
import { TextDirection } from "./util/TextDirection";

export class NoteFileLoader {
fileText: string;
Expand All @@ -16,14 +17,19 @@ export class NoteFileLoader {
this.settings = settings;
}

async load(noteFile: ISRFile, folderTopicPath: TopicPath): Promise<Note | null> {
async load(
noteFile: ISRFile,
defaultTextDirection: TextDirection,
folderTopicPath: TopicPath,
): Promise<Note | null> {
this.noteFile = noteFile;

const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings);

const onlyKeepQuestionsWithTopicPath: boolean = true;
const questionList: Question[] = await questionParser.createQuestionList(
noteFile,
defaultTextDirection,
folderTopicPath,
onlyKeepQuestionsWithTopicPath,
);
Expand Down
14 changes: 12 additions & 2 deletions src/NoteParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ISRFile } from "./SRFile";
import { Note } from "./Note";
import { SRSettings } from "./settings";
import { TopicPath } from "./TopicPath";
import { TextDirection } from "./util/TextDirection";

export class NoteParser {
settings: SRSettings;
Expand All @@ -12,9 +13,18 @@ export class NoteParser {
this.settings = settings;
}

async parse(noteFile: ISRFile, folderTopicPath: TopicPath): Promise<Note> {
async parse(
noteFile: ISRFile,
defaultTextDirection: TextDirection,
folderTopicPath: TopicPath,
): Promise<Note> {
const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings);
const questions = await questionParser.createQuestionList(noteFile, folderTopicPath, true);
const questions = await questionParser.createQuestionList(
noteFile,
defaultTextDirection,
folderTopicPath,
true,
);

const result: Note = new Note(noteFile, questions);
return result;
Expand Down
14 changes: 12 additions & 2 deletions src/NoteQuestionParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CardFrontBack, CardFrontBackUtil } from "./QuestionType";
import { SRSettings, SettingsUtil } from "./settings";
import { ISRFile, frontmatterTagPseudoLineNum } from "./SRFile";
import { TopicPath, TopicPathList } from "./TopicPath";
import { TextDirection } from "./util/TextDirection";
import { extractFrontmatter, splitTextIntoLineArray } from "./util/utils";

export class NoteQuestionParser {
Expand Down Expand Up @@ -40,6 +41,7 @@ export class NoteQuestionParser {

async createQuestionList(
noteFile: ISRFile,
defaultTextDirection: TextDirection,
folderTopicPath: TopicPath,
onlyKeepQuestionsWithTopicPath: boolean,
): Promise<Question[]> {
Expand All @@ -64,8 +66,11 @@ export class NoteQuestionParser {
[this.frontmatterText, this.contentText] = extractFrontmatter(noteText);

// Create the question list
let textDirection: TextDirection = noteFile.getTextDirection();
if (textDirection == TextDirection.Unspecified) textDirection = defaultTextDirection;
this.questionList = this.doCreateQuestionList(
noteText,
textDirection,
folderTopicPath,
this.tagCacheList,
);
Expand All @@ -89,6 +94,7 @@ export class NoteQuestionParser {

private doCreateQuestionList(
noteText: string,
textDirection: TextDirection,
folderTopicPath: TopicPath,
tagCacheList: TagCache[],
): Question[] {
Expand All @@ -100,7 +106,7 @@ export class NoteQuestionParser {
const result: Question[] = [];
const parsedQuestionInfoList: ParsedQuestionInfo[] = this.parseQuestions();
for (const parsedQuestionInfo of parsedQuestionInfoList) {
const question: Question = this.createQuestionObject(parsedQuestionInfo);
const question: Question = this.createQuestionObject(parsedQuestionInfo, textDirection);

// Each rawCardText can turn into multiple CardFrontBack's (e.g. CardType.Cloze, CardType.SingleLineReversed)
const cardFrontBackList: CardFrontBack[] = CardFrontBackUtil.expand(
Expand Down Expand Up @@ -144,14 +150,18 @@ export class NoteQuestionParser {
return result;
}

private createQuestionObject(parsedQuestionInfo: ParsedQuestionInfo): Question {
private createQuestionObject(
parsedQuestionInfo: ParsedQuestionInfo,
textDirection: TextDirection,
): Question {
const questionContext: string[] = this.noteFile.getQuestionContext(
parsedQuestionInfo.firstLineNum,
);
const result = Question.Create(
this.settings,
parsedQuestionInfo,
null, // We haven't worked out the TopicPathList yet
textDirection,
questionContext,
);
return result;
Expand Down
28 changes: 24 additions & 4 deletions src/Question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ParsedQuestionInfo } from "./parser";
import { SRSettings } from "./settings";
import { TopicPath, TopicPathList, TopicPathWithWs } from "./TopicPath";
import { MultiLineTextFinder } from "./util/MultiLineTextFinder";
import { TextDirection } from "./util/TextDirection";
import { cyrb53, stringTrimStart } from "./util/utils";

export enum CardType {
Expand Down Expand Up @@ -87,6 +88,9 @@ export class QuestionText {
// The question text, e.g. "Q1::A1" with leading/trailing whitespace as described above
actualQuestion: string;

// Either LTR or RTL
textDirection: TextDirection;

// The block identifier (optional), e.g. "^quote-of-the-day"
// Format of block identifiers:
// https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+block+in+a+note
Expand All @@ -102,11 +106,13 @@ export class QuestionText {
original: string,
topicPathWithWs: TopicPathWithWs,
actualQuestion: string,
textDirection: TextDirection,
blockId: string,
) {
this.original = original;
this.topicPathWithWs = topicPathWithWs;
this.actualQuestion = actualQuestion;
this.textDirection = textDirection;
this.obsidianBlockId = blockId;

// The hash is generated based on the topic and question, explicitly not the schedule or obsidian block ID
Expand All @@ -117,10 +123,14 @@ export class QuestionText {
return this.actualQuestion.endsWith("```");
}

static create(original: string, settings: SRSettings): QuestionText {
static create(
original: string,
textDirection: TextDirection,
settings: SRSettings,
): QuestionText {
const [topicPathWithWs, actualQuestion, blockId] = this.splitText(original, settings);

return new QuestionText(original, topicPathWithWs, actualQuestion, blockId);
return new QuestionText(original, topicPathWithWs, actualQuestion, textDirection, blockId);
}

static splitText(original: string, settings: SRSettings): [TopicPathWithWs, string, string] {
Expand Down Expand Up @@ -264,7 +274,12 @@ export class Question {

let newText = MultiLineTextFinder.findAndReplace(noteText, originalText, replacementText);
if (newText) {
this.questionText = QuestionText.create(replacementText, settings);
// Don't support changing the textDirection setting
this.questionText = QuestionText.create(
replacementText,
this.questionText.textDirection,
settings,
);
} else {
console.error(
`updateQuestionText: Text not found: ${originalText.substring(
Expand Down Expand Up @@ -293,10 +308,15 @@ export class Question {
settings: SRSettings,
parsedQuestionInfo: ParsedQuestionInfo,
noteTopicPathList: TopicPathList,
textDirection: TextDirection,
context: string[],
): Question {
const hasEditLaterTag = parsedQuestionInfo.text.includes(settings.editLaterTag);
const questionText: QuestionText = QuestionText.create(parsedQuestionInfo.text, settings);
const questionText: QuestionText = QuestionText.create(
parsedQuestionInfo.text,
textDirection,
settings,
);

let topicPathList: TopicPathList = noteTopicPathList;
if (questionText.topicPathWithWs) {
Expand Down
20 changes: 19 additions & 1 deletion src/SRFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import {
MetadataCache,
TFile,
Vault,
HeadingCache,
getAllTags as ObsidianGetAllTags,
HeadingCache,
TagCache,
FrontMatterCache,
} from "obsidian";
import { TextDirection } from "./util/TextDirection";
import { parseObsidianFrontmatterTag } from "./util/utils";

// NOTE: Line numbers are zero based
Expand All @@ -16,6 +17,7 @@ export interface ISRFile {
getAllTagsFromCache(): string[];
getAllTagsFromText(): TagCache[];
getQuestionContext(cardLine: number): string[];
getTextDirection(): TextDirection;
read(): Promise<string>;
write(content: string): Promise<void>;
}
Expand Down Expand Up @@ -111,6 +113,22 @@ export class SrTFile implements ISRFile {
return result;
}

getTextDirection(): TextDirection {
let result: TextDirection = TextDirection.Unspecified;
const fileCache = this.metadataCache.getFileCache(this.file);
const frontMatter = fileCache?.frontmatter;
if (frontMatter && frontMatter?.direction) {
// Don't know why the try/catch is needed; but copied from Obsidian RTL plug-in getFrontMatterDirection()
try {
const str: string = (frontMatter.direction + "").toLowerCase();
result = str == "rtl" ? TextDirection.Rtl : TextDirection.Ltr;
} catch (error) {
// continue regardless of error
}
}
return result;
}

async read(): Promise<string> {
return await this.vault.read(this.file);
}
Expand Down
18 changes: 14 additions & 4 deletions src/gui/EditModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { App, Modal } from "obsidian";
import { t } from "src/lang/helpers";
import { TextDirection } from "src/util/TextDirection";

// from https://github.com/chhoumann/quickadd/blob/bce0b4cdac44b867854d6233796e3406dfd163c6/src/gui/GenericInputPrompt/GenericInputPrompt.ts#L5
export class FlashcardEditModal extends Modal {
Expand All @@ -17,17 +18,23 @@ export class FlashcardEditModal extends Modal {
private rejectPromise: (reason?: any) => void;
private didSaveChanges = false;
private readonly modalText: string;

public static Prompt(app: App, placeholder: string): Promise<string> {
const newPromptModal = new FlashcardEditModal(app, placeholder);
private textDirection: TextDirection;

public static Prompt(
app: App,
placeholder: string,
textDirection: TextDirection,
): Promise<string> {
const newPromptModal = new FlashcardEditModal(app, placeholder, textDirection);
return newPromptModal.waitForClose;
}

constructor(app: App, existingText: string) {
constructor(app: App, existingText: string, textDirection: TextDirection) {
super(app);

this.modalText = existingText;
this.changedText = existingText;
this.textDirection = textDirection;

this.waitForClose = new Promise<string>((resolve, reject) => {
this.resolvePromise = resolve;
Expand Down Expand Up @@ -56,6 +63,9 @@ export class FlashcardEditModal extends Modal {
this.textArea.addClass("sr-input");
this.textArea.setText(this.modalText ?? "");
this.textArea.addEventListener("keydown", this.saveOnEnterCallback);
if (this.textDirection == TextDirection.Rtl) {
this.textArea.setAttribute("dir", "rtl");
}

this._createResponse(this.contentEl);
}
Expand Down
6 changes: 5 additions & 1 deletion src/gui/FlashcardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@ export class FlashcardModal extends Modal {
// Just the question/answer text; without any preceding topic tag
const textPrompt = currentQ.questionText.actualQuestion;

const editModal = FlashcardEditModal.Prompt(this.app, textPrompt);
const editModal = FlashcardEditModal.Prompt(
this.app,
textPrompt,
currentQ.questionText.textDirection,
);
editModal
.then(async (modifiedCardText) => {
this.reviewSequencer.updateCurrentQuestionText(modifiedCardText);
Expand Down
12 changes: 10 additions & 2 deletions src/gui/FlashcardReviewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ export class FlashcardReviewView {
this.plugin,
this._currentNote.filePath,
);
await wrapper.renderMarkdownWrapper(this._currentCard.front, this.content);
await wrapper.renderMarkdownWrapper(
this._currentCard.front,
this.content,
this._currentQuestion.questionText.textDirection,
);
// Set scroll position back to top
this.content.scrollTop = 0;

Expand Down Expand Up @@ -292,7 +296,11 @@ export class FlashcardReviewView {
this.plugin,
this._currentNote.filePath,
);
wrapper.renderMarkdownWrapper(this._currentCard.back, this.content);
wrapper.renderMarkdownWrapper(
this._currentCard.back,
this.content,
this._currentQuestion.questionText.textDirection,
);

// Show response buttons
this.answerButton.addClass("sr-is-hidden");
Expand Down
Loading

0 comments on commit 3228e9c

Please sign in to comment.