Repository: st3v3nmw/obsidian-spaced-repetition
Pull request #935 (2024-04-18 02:54:50)

Support RTL flashcards specified by frontmatter "direction" attribute
This addresses issue [RTL Support](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/335)

User documentation also updated to describe.


ronzulu: The "lint_and_test" step fails even though this PR did not change any configuration files.

See [ [DEV] Code submission "lint_and_test" fails with error ERR_PNPM_OTHER_PM_EXPECTED #936 ](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/936)
st3v3nmw: > The "lint_and_test" step fails even though this PR did not change any configuration files.

Fixed in #939
ronzulu: Hi @st3v3nmw , hope you are doing okay.

If you could please review this so that we can merge it into master.

Thanks
Ronny

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 9479ea1..62df2be 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -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)
diff --git a/docs/en/flashcards.md b/docs/en/flashcards.md
index afabfdc..8bfc5b1 100644
--- a/docs/en/flashcards.md
+++ b/docs/en/flashcards.md
@@ -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.
diff --git a/src/NoteFileLoader.ts b/src/NoteFileLoader.ts
index 252e863..f63e067 100644
--- a/src/NoteFileLoader.ts
+++ b/src/NoteFileLoader.ts
@@ -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;
@@ -16,7 +17,11 @@ 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);
@@ -24,6 +29,7 @@ export class NoteFileLoader {
         const onlyKeepQuestionsWithTopicPath: boolean = true;
         const questionList: Question[] = await questionParser.createQuestionList(
             noteFile,
+            defaultTextDirection,
             folderTopicPath,
             onlyKeepQuestionsWithTopicPath,
         );
diff --git a/src/NoteParser.ts b/src/NoteParser.ts
index 15b7fdb..f636895 100644
--- a/src/NoteParser.ts
+++ b/src/NoteParser.ts
@@ -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;
@@ -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;
diff --git a/src/NoteQuestionParser.ts b/src/NoteQuestionParser.ts
index 0b8a430..acb668c 100644
--- a/src/NoteQuestionParser.ts
+++ b/src/NoteQuestionParser.ts
@@ -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 {
@@ -40,6 +41,7 @@ export class NoteQuestionParser {
 
     async createQuestionList(
         noteFile: ISRFile,
+        defaultTextDirection: TextDirection,
         folderTopicPath: TopicPath,
         onlyKeepQuestionsWithTopicPath: boolean,
     ): Promise<Question[]> {
@@ -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,
             );
@@ -89,6 +94,7 @@ export class NoteQuestionParser {
 
     private doCreateQuestionList(
         noteText: string,
+        textDirection: TextDirection,
         folderTopicPath: TopicPath,
         tagCacheList: TagCache[],
     ): Question[] {
@@ -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(
@@ -144,7 +150,10 @@ 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,
         );
@@ -152,6 +161,7 @@ export class NoteQuestionParser {
             this.settings,
             parsedQuestionInfo,
             null, // We haven't worked out the TopicPathList yet
+            textDirection,
             questionContext,
         );
         return result;
diff --git a/src/Question.ts b/src/Question.ts
index 16b1b27..6541ca2 100644
--- a/src/Question.ts
+++ b/src/Question.ts
@@ -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 {
@@ -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
@@ -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
@@ -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] {
@@ -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(
@@ -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) {
diff --git a/src/SRFile.ts b/src/SRFile.ts
index eb71ca1..bee9b32 100644
--- a/src/SRFile.ts
+++ b/src/SRFile.ts
@@ -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
@@ -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>;
 }
@@ -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);
     }
diff --git a/src/gui/EditModal.tsx b/src/gui/EditModal.tsx
index 1feb19d..35c9a7c 100644
--- a/src/gui/EditModal.tsx
+++ b/src/gui/EditModal.tsx
@@ -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 {
@@ -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;
@@ -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);
     }
diff --git a/src/gui/FlashcardModal.tsx b/src/gui/FlashcardModal.tsx
index f679a7e..ddadb7d 100644
--- a/src/gui/FlashcardModal.tsx
+++ b/src/gui/FlashcardModal.tsx
@@ -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);
diff --git a/src/gui/FlashcardReviewView.tsx b/src/gui/FlashcardReviewView.tsx
index 762416d..18dd25e 100644
--- a/src/gui/FlashcardReviewView.tsx
+++ b/src/gui/FlashcardReviewView.tsx
@@ -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;
 
@@ -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");
diff --git a/src/main.ts b/src/main.ts
index 412a837..43f3354 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -41,6 +41,8 @@ import { NoteEaseCalculator } from "./NoteEaseCalculator";
 import { DeckTreeStatsCalculator } from "./DeckTreeStatsCalculator";
 import { NoteEaseList } from "./NoteEaseList";
 import { QuestionPostponementList } from "./QuestionPostponementList";
+import { TextDirection } from "./util/TextDirection";
+import { convertToStringOrEmpty } from "./util/utils";
 import { isEqualOrSubPath } from "./util/utils";
 
 interface PluginData {
@@ -555,15 +557,27 @@ export default class SRPlugin extends Plugin {
             this.data.settings,
         );
 
-        const note: Note = await loader.load(this.createSrTFile(noteFile), folderTopicPath);
+        const note: Note = await loader.load(
+            this.createSrTFile(noteFile),
+            this.getObsidianRtlSetting(),
+            folderTopicPath,
+        );
         if (note.hasChanged) {
             note.writeNoteFile(this.data.settings);
         }
         return note;
     }
 
+    private getObsidianRtlSetting(): TextDirection {
+        // Get the direction with Obsidian's own setting
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const v: any = (this.app.vault as any).getConfig("rightToLeft");
+        return convertToStringOrEmpty(v) == "true" ? TextDirection.Rtl : TextDirection.Ltr;
+    }
+
     async saveReviewResponse(note: TFile, response: ReviewResponse): Promise<void> {
         const fileCachedData = this.app.metadataCache.getFileCache(note) || {};
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const frontmatter: FrontMatterCache | Record<string, unknown> =
             fileCachedData.frontmatter || {};
 
diff --git a/src/util/RenderMarkdownWrapper.ts b/src/util/RenderMarkdownWrapper.ts
index 9e18784..8422b8c 100644
--- a/src/util/RenderMarkdownWrapper.ts
+++ b/src/util/RenderMarkdownWrapper.ts
@@ -6,6 +6,7 @@ import {
     NON_LETTER_SYMBOLS_REGEX,
 } from "../constants";
 import SRPlugin from "../main";
+import { TextDirection } from "./TextDirection";
 
 export class RenderMarkdownWrapper {
     private app: App;
@@ -23,13 +24,19 @@ export class RenderMarkdownWrapper {
     async renderMarkdownWrapper(
         markdownString: string,
         containerEl: HTMLElement,
+        textDirection: TextDirection,
         recursiveDepth = 0,
     ): Promise<void> {
         if (recursiveDepth > 4) return;
 
-        MarkdownRenderer.render(this.app, markdownString, containerEl, this.notePath, this.plugin);
+        let el: HTMLElement;
+        if (textDirection == TextDirection.Rtl) {
+            el = containerEl.createDiv();
+            el.setAttribute("dir", "rtl");
+        } else el = containerEl;
+        MarkdownRenderer.render(this.app, markdownString, el, this.notePath, this.plugin);
 
-        containerEl.findAll(".internal-embed").forEach((el) => {
+        el.findAll(".internal-embed").forEach((el) => {
             const link = this.parseLink(el.getAttribute("src"));
 
             // file does not exist, display dead link
@@ -145,6 +152,9 @@ export class RenderMarkdownWrapper {
             blockText = text;
         }
 
-        this.renderMarkdownWrapper(blockText, el, recursiveDepth + 1);
+        // We are operating here within the parent container.
+        // It already has the rtl div if necessary.
+        // We don't need another rtl div, so we can set direction to Unspecified
+        this.renderMarkdownWrapper(blockText, el, TextDirection.Unspecified, recursiveDepth + 1);
     }
 }
diff --git a/src/util/TextDirection.ts b/src/util/TextDirection.ts
new file mode 100644
index 0000000..a49807c
--- /dev/null
+++ b/src/util/TextDirection.ts
@@ -0,0 +1,5 @@
+export enum TextDirection {
+    Unspecified,
+    Ltr,
+    Rtl,
+}
diff --git a/src/util/utils.ts b/src/util/utils.ts
index 7b872de..2924771 100644
--- a/src/util/utils.ts
+++ b/src/util/utils.ts
@@ -1,7 +1,7 @@
 import moment from "moment";
 import { Moment } from "moment";
 import { normalize, sep } from "path";
-import { PREFERRED_DATE_FORMAT, YAML_FRONT_MATTER_REGEX } from "src/constants";
+import { PREFERRED_DATE_FORMAT } from "src/constants";
 
 type Hex = number;
 
@@ -97,6 +97,28 @@ export function stringTrimStart(str: string): [string, string] {
     return [ws, trimmed];
 }
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function convertToStringOrEmpty(v: any): string {
+    let result: string = "";
+    if (v != null && v != undefined) {
+        result = v + "";
+    }
+    return result;
+}
+
+//
+// This returns [frontmatter, content]
+//
+// The returned content has the same number of lines as the supplied str string, but with the
+// frontmatter lines (if present) blanked out.
+//
+// 1. We don't want the parser to see the frontmatter, as it would deem it to be part of a multi-line question
+// if one started on the line immediately after the "---" closing marker.
+//
+// 2. The lines are blanked out rather than deleted so that line numbers are not affected
+// e.g. for calls to getQuestionContext(cardLine: number)
+//
+
 /**
  * Checks a path is equal or a subpath of the other rootPath
  *


Test patch:
diff --git a/tests/unit/Note.test.ts b/tests/unit/Note.test.ts
index c38af8b..ec82123 100644
--- a/tests/unit/Note.test.ts
+++ b/tests/unit/Note.test.ts
@@ -5,6 +5,7 @@ import { Note } from "src/Note";
 import { Question } from "src/Question";
 import { DEFAULT_SETTINGS } from "src/settings";
 import { NoteFileLoader } from "src/NoteFileLoader";
+import { TextDirection } from "src/util/TextDirection";
 import { UnitTestSRFile } from "./helpers/UnitTestSRFile";
 
 let parser: NoteParser = new NoteParser(DEFAULT_SETTINGS);
@@ -19,7 +20,7 @@ Q3::A3
 `;
         let file: UnitTestSRFile = new UnitTestSRFile(noteText);
         let folderTopicPath = TopicPath.emptyPath;
-        let note: Note = await parser.parse(file, folderTopicPath);
+        let note: Note = await parser.parse(file, TextDirection.Ltr, folderTopicPath);
         let deck: Deck = Deck.emptyDeck;
         note.appendCardsToDeck(deck);
         let subdeck: Deck = deck.getDeck(new TopicPath(["flashcards", "test"]));
@@ -37,7 +38,7 @@ Q3:::A3
 `;
         let file: UnitTestSRFile = new UnitTestSRFile(noteText);
         let folderTopicPath = TopicPath.emptyPath;
-        let note: Note = await parser.parse(file, folderTopicPath);
+        let note: Note = await parser.parse(file, TextDirection.Ltr, folderTopicPath);
         let deck: Deck = Deck.emptyDeck;
         note.appendCardsToDeck(deck);
         let subdeck: Deck = deck.getDeck(new TopicPath(["flashcards", "test"]));
@@ -59,7 +60,7 @@ Q3:::A3
 <!--SR:!2023-09-02,4,270!2023-09-02,5,270!2023-09-02,6,270!2023-09-02,7,270-->
 `;
         let file: UnitTestSRFile = new UnitTestSRFile(originalText);
-        let note: Note = await noteFileLoader.load(file, TopicPath.emptyPath);
+        let note: Note = await noteFileLoader.load(file, TextDirection.Ltr, TopicPath.emptyPath);
 
         await note.writeNoteFile(DEFAULT_SETTINGS);
         let updatedText: string = file.content;
diff --git a/tests/unit/NoteFileLoader.test.ts b/tests/unit/NoteFileLoader.test.ts
index ce2c2fb..c60db47 100644
--- a/tests/unit/NoteFileLoader.test.ts
+++ b/tests/unit/NoteFileLoader.test.ts
@@ -2,6 +2,7 @@ import { Note } from "src/Note";
 import { NoteFileLoader } from "src/NoteFileLoader";
 import { TopicPath } from "src/TopicPath";
 import { DEFAULT_SETTINGS } from "src/settings";
+import { TextDirection } from "src/util/TextDirection";
 import { UnitTestSRFile } from "./helpers/UnitTestSRFile";
 
 var noteFileLoader: NoteFileLoader = new NoteFileLoader(DEFAULT_SETTINGS);
@@ -16,7 +17,7 @@ Q3:::A3
 <!--SR:!2023-09-02,4,270-->
 `;
         let file: UnitTestSRFile = new UnitTestSRFile(noteText);
-        let note: Note = await noteFileLoader.load(file, TopicPath.emptyPath);
+        let note: Note = await noteFileLoader.load(file, TextDirection.Ltr, TopicPath.emptyPath);
         expect(note.hasChanged).toEqual(false);
     });
 
@@ -29,7 +30,7 @@ Q3:::A3
 <!--SR:!2023-09-02,4,270-->
 `;
         let file: UnitTestSRFile = new UnitTestSRFile(noteText);
-        let note: Note = await noteFileLoader.load(file, TopicPath.emptyPath);
+        let note: Note = await noteFileLoader.load(file, TextDirection.Ltr, TopicPath.emptyPath);
         expect(note.hasChanged).toEqual(true);
     });
 });
diff --git a/tests/unit/NoteParser.test.ts b/tests/unit/NoteParser.test.ts
index a776849..3f62bbc 100644
--- a/tests/unit/NoteParser.test.ts
+++ b/tests/unit/NoteParser.test.ts
@@ -4,6 +4,7 @@ import { Note } from "src/Note";
 import { Question } from "src/Question";
 import { DEFAULT_SETTINGS } from "src/settings";
 import { setupStaticDateProvider_20230906 } from "src/util/DateProvider";
+import { TextDirection } from "src/util/TextDirection";
 import { UnitTestSRFile } from "./helpers/UnitTestSRFile";
 
 let parser: NoteParser = new NoteParser(DEFAULT_SETTINGS);
@@ -21,7 +22,7 @@ Q3::A3
 `;
         let file: UnitTestSRFile = new UnitTestSRFile(noteText);
         let folderTopicPath = TopicPath.emptyPath;
-        let note: Note = await parser.parse(file, folderTopicPath);
+        let note: Note = await parser.parse(file, TextDirection.Ltr, folderTopicPath);
         let questionList = note.questionList;
         expect(questionList.length).toEqual(3);
     });
diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts
index ae42d74..ceee063 100644
--- a/tests/unit/NoteQuestionParser.test.ts
+++ b/tests/unit/NoteQuestionParser.test.ts
@@ -7,6 +7,7 @@ import { TopicPath, TopicPathList } from "src/TopicPath";
 import { createTest_NoteQuestionParser } from "./SampleItems";
 import { ISRFile, frontmatterTagPseudoLineNum } from "src/SRFile";
 import { setupStaticDateProvider_20230906 } from "src/util/DateProvider";
+import { TextDirection } from "src/util/TextDirection";
 import { UnitTestSRFile } from "./helpers/UnitTestSRFile";
 import { Card } from "src/Card";
 
@@ -28,7 +29,12 @@ describe("No flashcard questions", () => {
         let noteFile: ISRFile = new UnitTestSRFile(noteText);
 
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toEqual([]);
     });
 
@@ -38,7 +44,12 @@ describe("No flashcard questions", () => {
         let noteFile: ISRFile = new UnitTestSRFile(noteText);
 
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toEqual([]);
     });
 });
@@ -70,7 +81,12 @@ A::B
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 
@@ -109,7 +125,12 @@ A::B
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 
@@ -131,7 +152,12 @@ A::B
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 
@@ -160,7 +186,12 @@ In computer-science, a *heap* is a tree-based data-structure, that satisfies the
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 });
@@ -199,7 +230,12 @@ A::B ^d7cee0
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 
@@ -244,7 +280,12 @@ A::B ^d7cee0
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 
@@ -287,7 +328,12 @@ A::B <!--SR:!2023-09-03,1,230--> ^d7cee0
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 
@@ -329,7 +375,12 @@ A::B <!--SR:!2023-09-03,1,230--> ^d7cee0
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 });
@@ -344,6 +395,7 @@ Q2::A2
         let folderTopicPath: TopicPath = TopicPath.emptyPath;
         let questionList: Question[] = await parser_ConvertFoldersToDecks.createQuestionList(
             noteFile,
+            TextDirection.Ltr,
             folderTopicPath,
             true,
         );
@@ -361,6 +413,7 @@ Q3::A3
         let folderTopicPath: TopicPath = new TopicPath(["flashcards", "science"]);
         let questionList: Question[] = await parser_ConvertFoldersToDecks.createQuestionList(
             noteFile,
+            TextDirection.Ltr,
             folderTopicPath,
             true,
         );
@@ -387,6 +440,7 @@ Q3::A3
         let folderTopicPath: TopicPath = TopicPath.emptyPath;
         let questionList: Question[] = await parserWithDefaultSettings.createQuestionList(
             noteFile,
+            TextDirection.Ltr,
             folderTopicPath,
             true,
         );
@@ -416,6 +470,7 @@ Multiline answer2
 
         let questionList: Question[] = await parserWithDefaultSettings.createQuestionList(
             noteFile,
+            TextDirection.Ltr,
             TopicPath.emptyPath,
             true,
         );
@@ -462,6 +517,7 @@ describe("Handling tags within note", () => {
             let folderTopicPath: TopicPath = new TopicPath(["folder", "subfolder"]);
             let questionList: Question[] = await parser2.createQuestionList(
                 noteFile,
+                TextDirection.Ltr,
                 folderTopicPath,
                 true,
             );
@@ -479,6 +535,7 @@ Q1::A1
             let folderTopicPath: TopicPath = new TopicPath(["folder", "subfolder"]);
             let questionList: Question[] = await parser2.createQuestionList(
                 noteFile,
+                TextDirection.Ltr,
                 folderTopicPath,
                 true,
             );
@@ -498,6 +555,7 @@ Q1::A1
             let folderTopicPath: TopicPath = new TopicPath(["folder", "subfolder"]);
             let questionList: Question[] = await parser2.createQuestionList(
                 noteFile,
+                TextDirection.Ltr,
                 folderTopicPath,
                 true,
             );
@@ -521,6 +579,7 @@ Q1::A1
             let folderTopicPath: TopicPath = TopicPath.emptyPath;
             let questionList: Question[] = await parserWithDefaultSettings.createQuestionList(
                 noteFile,
+                TextDirection.Ltr,
                 folderTopicPath,
                 true,
             );
@@ -558,7 +617,12 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу
                 },
             ];
             expect(
-                await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+                await parserWithDefaultSettings.createQuestionList(
+                    noteFile,
+                    TextDirection.Ltr,
+                    folderTopicPath,
+                    true,
+                ),
             ).toMatchObject(expected);
         });
 
@@ -573,6 +637,7 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу
             let folderTopicPath: TopicPath = TopicPath.emptyPath;
             let questionList: Question[] = await parserWithDefaultSettings.createQuestionList(
                 noteFile,
+                TextDirection.Ltr,
                 folderTopicPath,
                 true,
             );
@@ -595,6 +660,7 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу
             let folderTopicPath: TopicPath = TopicPath.emptyPath;
             let questionList: Question[] = await parserWithDefaultSettings.createQuestionList(
                 noteFile,
+                TextDirection.Ltr,
                 folderTopicPath,
                 true,
             );
@@ -616,6 +682,7 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу
             let folderTopicPath: TopicPath = TopicPath.emptyPath;
             let questionList: Question[] = await parserWithDefaultSettings.createQuestionList(
                 noteFile,
+                TextDirection.Ltr,
                 folderTopicPath,
                 true,
             );
@@ -639,6 +706,7 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу
             let folderTopicPath: TopicPath = TopicPath.emptyPath;
             let questionList: Question[] = await parserWithDefaultSettings.createQuestionList(
                 noteFile,
+                TextDirection.Ltr,
                 folderTopicPath,
                 true,
             );
@@ -680,7 +748,12 @@ What year was the Taliban Emirate founded?::1996 #flashcards
                 },
             ];
             expect(
-                await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+                await parserWithDefaultSettings.createQuestionList(
+                    noteFile,
+                    TextDirection.Ltr,
+                    folderTopicPath,
+                    true,
+                ),
             ).toMatchObject(expected);
         });
     });
@@ -717,7 +790,12 @@ In computer-science, a *heap* is a tree-based data-structure, that satisfies the
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 
@@ -748,7 +826,12 @@ In computer-science, a *heap* is a tree-based data-structure, that satisfies the
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 
@@ -788,7 +871,12 @@ A::B
             },
         ];
         expect(
-            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true),
+            await parserWithDefaultSettings.createQuestionList(
+                noteFile,
+                TextDirection.Ltr,
+                folderTopicPath,
+                true,
+            ),
         ).toMatchObject(expected);
     });
 });
diff --git a/tests/unit/Question.test.ts b/tests/unit/Question.test.ts
index 8ba2928..e6c65c9 100644
--- a/tests/unit/Question.test.ts
+++ b/tests/unit/Question.test.ts
@@ -1,6 +1,7 @@
 import { TopicPath } from "src/TopicPath";
 import { DEFAULT_SETTINGS, SRSettings } from "src/settings";
 import { Question, QuestionText } from "src/Question";
+import { TextDirection } from "src/util/TextDirection";
 
 let settings_cardCommentOnSameLine: SRSettings = { ...DEFAULT_SETTINGS };
 settings_cardCommentOnSameLine.cardCommentOnSameLine = true;
@@ -13,7 +14,7 @@ describe("Question", () => {
                 "```\nprint('Hello World!')\nprint('Howdy?')\nlambda x: x[0]\n```";
 
             let question: Question = new Question({
-                questionText: new QuestionText(text, null, text, null),
+                questionText: new QuestionText(text, null, text, TextDirection.Ltr, null),
             });
 
             expect(question.getHtmlCommentSeparator(DEFAULT_SETTINGS)).toEqual("\n");
@@ -24,7 +25,7 @@ describe("Question", () => {
             let text: string = "Q1::A1";
 
             let question: Question = new Question({
-                questionText: new QuestionText(text, null, text, null),
+                questionText: new QuestionText(text, null, text, TextDirection.Ltr, null),
             });
 
             expect(question.getHtmlCommentSeparator(DEFAULT_SETTINGS)).toEqual("\n");
diff --git a/tests/unit/SampleItems.ts b/tests/unit/SampleItems.ts
index da071b5..b928032 100644
--- a/tests/unit/SampleItems.ts
+++ b/tests/unit/SampleItems.ts
@@ -7,6 +7,7 @@ import { CardType, Question } from "src/Question";
 import { CardFrontBack, CardFrontBackUtil } from "src/QuestionType";
 import { DEFAULT_SETTINGS, SRSettings } from "src/settings";
 import { TopicPath } from "src/TopicPath";
+import { TextDirection } from "src/util/TextDirection";
 import { UnitTestSRFile } from "./helpers/UnitTestSRFile";
 import { CardOrder, DeckOrder, DeckTreeIterator } from "src/DeckTreeIterator";
 
@@ -70,7 +71,7 @@ Q3::A3`;
     ): Promise<Deck> {
         let deck: Deck = new Deck("Root", null);
         let noteParser: NoteParser = createTest_NoteParser();
-        let note: Note = await noteParser.parse(file, folderTopicPath);
+        let note: Note = await noteParser.parse(file, TextDirection.Ltr, folderTopicPath);
         note.appendCardsToDeck(deck);
         return deck;
     }
diff --git a/tests/unit/helpers/UnitTestSRFile.ts b/tests/unit/helpers/UnitTestSRFile.ts
index 4878ca8..4c49849 100644
--- a/tests/unit/helpers/UnitTestSRFile.ts
+++ b/tests/unit/helpers/UnitTestSRFile.ts
@@ -1,6 +1,7 @@
 import { TagCache } from "obsidian";
 import { ISRFile } from "src/SRFile";
 import { unitTest_GetAllTagsFromTextEx } from "./UnitTestHelper";
+import { TextDirection } from "src/util/TextDirection";
 
 export class UnitTestSRFile implements ISRFile {
     content: string;
@@ -32,6 +33,10 @@ export class UnitTestSRFile implements ISRFile {
         return [];
     }
 
+    getTextDirection(): TextDirection {
+        return TextDirection.Unspecified;
+    }
+
     async read(): Promise<string> {
         return this.content;
     }
diff --git a/tests/unit/util/utils.test.ts b/tests/unit/util/utils.test.ts
index efb4a23..e5391ed 100644
--- a/tests/unit/util/utils.test.ts
+++ b/tests/unit/util/utils.test.ts
@@ -1,5 +1,6 @@
 import { YAML_FRONT_MATTER_REGEX } from "src/constants";
 import {
+    convertToStringOrEmpty,
     extractFrontmatter,
     findLineIndexOfSearchStringIgnoringWs,
     isEqualOrSubPath,
@@ -79,6 +80,28 @@ $$\\huge F_g=\\frac {G m_1 m_2}{d^2}$$
     });
 });
 
+describe("convertToStringOrEmpty", () => {
+    test("undefined returns empty string", () => {
+        expect(convertToStringOrEmpty(undefined)).toEqual("");
+    });
+
+    test("null returns empty string", () => {
+        expect(convertToStringOrEmpty(null)).toEqual("");
+    });
+
+    test("empty string returns empty string", () => {
+        expect(convertToStringOrEmpty("")).toEqual("");
+    });
+
+    test("string returned unchanged", () => {
+        expect(convertToStringOrEmpty("Hello")).toEqual("Hello");
+    });
+
+    test("number is converted to string", () => {
+        expect(convertToStringOrEmpty(5)).toEqual("5");
+    });
+});
+
 function createTestStr1(sep: string): string {
     return `---${sep}sr-due: 2024-08-10${sep}sr-interval: 273${sep}sr-ease: 309${sep}---`;
 }