Repository: st3v3nmw/obsidian-spaced-repetition (ts)

Issue:
[BUG] HTML review comment deactivates block identifier
Thanks a lot for creating this plugin, @st3v3nmw! It has been extremely useful for me.
There is just a small issue when creating flashcards from linked blocks.

**Describe the bug**
Whenever a HTML review comment is added to a flashcard containting a block identifier, the block identifier gets deactivated. This results in the other file being unable to locate that block.

<img width="164" alt="image" src="https://github.com/st3v3nmw/obsidian-spaced-repetition/assets/154070933/928eec97-cd50-4e38-a7e6-3fc47f39af37">

becomes this:

<img width="386" alt="image" src="https://github.com/st3v3nmw/obsidian-spaced-repetition/assets/154070933/2808a64c-5d00-4194-a413-512f34a7b3cb">

(I should probably add that I configured ">>" to be my flashcard identifier)

Adding the HTML comment on a new line solves this, but this unfortunately breaks list formatting in other flashcards.

Any help is appreciated!

Pull request:
Support Obsidian block IDs on same line as flashcard scheduling comment
Fixes #815 [BUG] HTML review comment deactivates block identifier

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bed66b..1cc39a6 120000
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,1 +1,1 @@
-docs/changelog.md
\ No newline at end of file
+docs/changelog.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 651dc17..9815d5b 120000
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,1 +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 de75b9c..b344809 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -4,6 +4,10 @@ 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]
+
+-   Fixes bug #815 HTML review comment deactivates block identifier https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/815
+
 #### [1.11.1](https://github.com/st3v3nmw/obsidian-spaced-repetition/compare/1.11.0...1.11.1)
 
 -   chore: fix README to point to new project board [`#848`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/848)
diff --git a/src/Question.ts b/src/Question.ts
index 58dda1d..1e18138 100644
--- a/src/Question.ts
+++ b/src/Question.ts
@@ -1,6 +1,7 @@
 import { Card } from "./Card";
 import { CardScheduleInfo, NoteCardScheduleParser } from "./CardSchedule";
 import {
+    OBSIDIAN_BLOCK_ID_ENDOFLINE_REGEX,
     OBSIDIAN_TAG_AT_STARTOFLINE_REGEX,
     SR_HTML_COMMENT_BEGIN,
     SR_HTML_COMMENT_END,
@@ -30,6 +31,8 @@ export enum CardType {
 //          (whitespace always included when text is generated by formatForNote(), would only
 //          be missing if manually removed by the user)
 //
+//      4. Obsidian block identifier (optional)
+//
 // Actual Question - Whitespace Handling
 //
 //      It is important that whitespace is maintained accurately by this class.
@@ -66,6 +69,12 @@ export enum CardType {
 //  Question text with card schedule info:
 //      #flashcards/science  Q2::A2 <!--SR:!2023-10-16,34,290-->
 //
+//  Question text with card schedule info and block identifier:
+//      #flashcards/science  Q2::A2 <!--SR:!2023-10-16,34,290--> ^d7cee0
+//
+//  Question text with block identifier:
+//      Q2::A2 ^d7cee0
+//
 export class QuestionText {
     // Complete text including all components, as read from file
     original: string;
@@ -77,15 +86,30 @@ export class QuestionText {
     // The question text, e.g. "Q1::A1" with leading/trailing whitespace as described above
     actualQuestion: string;
 
+    // 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
+    //      Block identifiers can only consist of letters, numbers, and dashes.
+    // If present, then first character is "^"
+    obsidianBlockId: string;
+
     // Hash of string  (topicPath + actualQuestion)
     // Explicitly excludes the HTML comment with the scheduling info
     textHash: string;
 
-    constructor(original: string, topicPathWithWs: TopicPathWithWs, actualQuestion: string) {
+    constructor(
+        original: string,
+        topicPathWithWs: TopicPathWithWs,
+        actualQuestion: string,
+        blockId: string,
+    ) {
         this.original = original;
         this.topicPathWithWs = topicPathWithWs;
         this.actualQuestion = actualQuestion;
-        this.textHash = cyrb53(this.formatForNote());
+        this.obsidianBlockId = blockId;
+
+        // The hash is generated based on the topic and question, explicitly not the schedule or obsidian block ID
+        this.textHash = cyrb53(this.formatTopicAndQuestion());
     }
 
     endsWithCodeBlock(): boolean {
@@ -93,38 +117,54 @@ export class QuestionText {
     }
 
     static create(original: string, settings: SRSettings): QuestionText {
-        const [topicPathWithWs, actualQuestion] = this.splitText(original, settings);
+        const [topicPathWithWs, actualQuestion, blockId] = this.splitText(original, settings);
 
-        return new QuestionText(original, topicPathWithWs, actualQuestion);
+        return new QuestionText(original, topicPathWithWs, actualQuestion, blockId);
     }
 
-    static splitText(original: string, settings: SRSettings): [TopicPathWithWs, string] {
+    static splitText(original: string, settings: SRSettings): [TopicPathWithWs, string, string] {
         const originalWithoutSR = NoteCardScheduleParser.removeCardScheduleInfo(original);
         let actualQuestion: string = originalWithoutSR.trimEnd();
 
         let topicPathWithWs: TopicPathWithWs = null;
-        if (!settings.convertFoldersToDecks) {
-            const topicPath = TopicPath.getTopicPathFromCardText(originalWithoutSR);
-            if (topicPath?.hasPath) {
-                // originalWithoutSR - preTopicPathWs TopicPath postTopicPathWs Question
-                // cardText1 - TopicPath postTopicPathWs Question
-                const [preTopicPathWs, cardText1] = stringTrimStart(originalWithoutSR);
-                const actualQuestionWithWs: string = cardText1.replaceAll(
-                    OBSIDIAN_TAG_AT_STARTOFLINE_REGEX,
-                    "",
-                );
-
-                // actualQuestionWithWs - postTopicPathWs Question
-                let postTopicPathWs: string;
-                [postTopicPathWs, actualQuestion] = stringTrimStart(actualQuestionWithWs);
+        let blockId: string = null;
+
+        // originalWithoutSR - [[preTopicPathWs] TopicPath [postTopicPathWs]] Question [whitespace blockId]
+        const topicPath = TopicPath.getTopicPathFromCardText(originalWithoutSR);
+        if (topicPath?.hasPath) {
+            // cardText2 - TopicPath postTopicPathWs Question [whitespace blockId]
+            const [preTopicPathWs, cardText2] = stringTrimStart(originalWithoutSR);
+
+            // cardText3 - postTopicPathWs Question [whitespace blockId]
+            const cardText3: string = cardText2.replaceAll(OBSIDIAN_TAG_AT_STARTOFLINE_REGEX, "");
+
+            // actualQuestion - Question [whitespace blockId]
+            let postTopicPathWs: string = null;
+            [postTopicPathWs, actualQuestion] = stringTrimStart(cardText3);
+            if (!settings.convertFoldersToDecks) {
                 topicPathWithWs = new TopicPathWithWs(topicPath, preTopicPathWs, postTopicPathWs);
             }
         }
 
-        return [topicPathWithWs, actualQuestion];
+        // actualQuestion - Question [whitespace blockId]
+        [actualQuestion, blockId] = this.extractObsidianBlockId(actualQuestion);
+
+        return [topicPathWithWs, actualQuestion, blockId];
     }
 
-    formatForNote(): string {
+    static extractObsidianBlockId(text: string): [string, string] {
+        let question: string = text;
+        let blockId: string = null;
+        const match = text.match(OBSIDIAN_BLOCK_ID_ENDOFLINE_REGEX);
+        if (match) {
+            blockId = match[0].trim();
+            const newLength = question.length - blockId.length;
+            question = question.substring(0, newLength).trimEnd();
+        }
+        return [question, blockId];
+    }
+
+    formatTopicAndQuestion(): string {
         let result: string = "";
         if (this.topicPathWithWs) {
             result += this.topicPathWithWs.formatWithWs();
@@ -151,14 +191,19 @@ export class Question {
     }
 
     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";
-        }
+        const sep: string = this.isCardCommentsOnSameLine(settings) ? " " : "\n";
         return sep;
     }
 
+    isCardCommentsOnSameLine(settings: SRSettings): boolean {
+        let result: boolean = settings.cardCommentOnSameLine;
+        // Schedule info must be on next line if last block is a codeblock
+        if (this.questionText.endsWithCodeBlock()) {
+            result = false;
+        }
+        return result;
+    }
+
     setCardList(cards: Card[]): void {
         this.cards = cards;
         this.cards.forEach((card) => (card.question = this));
@@ -181,12 +226,22 @@ export class Question {
     }
 
     formatForNote(settings: SRSettings): string {
-        let result: string = this.questionText.formatForNote();
-        if (this.cards.some((card) => card.hasSchedule)) {
-            result =
-                result.trimEnd() +
-                this.getHtmlCommentSeparator(settings) +
-                this.formatScheduleAsHtmlComment(settings);
+        let result: string = this.questionText.formatTopicAndQuestion();
+        const blockId: string = this.questionText.obsidianBlockId;
+        const hasSchedule: boolean = this.cards.some((card) => card.hasSchedule);
+        if (hasSchedule) {
+            result = result.trimEnd();
+            const scheduleHtml = this.formatScheduleAsHtmlComment(settings);
+            if (blockId) {
+                if (this.isCardCommentsOnSameLine(settings))
+                    result += ` ${scheduleHtml} ${blockId}`;
+                else result += ` ${blockId}\n${scheduleHtml}`;
+            } else {
+                result += this.getHtmlCommentSeparator(settings) + scheduleHtml;
+            }
+        } else {
+            // No schedule, so the block ID always comes after the question text, without anything after it
+            if (blockId) result += ` ${blockId}`;
         }
         return result;
     }
diff --git a/src/constants.ts b/src/constants.ts
index 18d1162..6319a83 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -8,6 +8,13 @@ export const NON_LETTER_SYMBOLS_REGEX = /[!-/:-@[-`{-~}\s]/g;
 export const MULTI_SCHEDULING_EXTRACTOR = /!([\d-]+),(\d+),(\d+)/gm;
 export const LEGACY_SCHEDULING_EXTRACTOR = /<!--SR:([\d-]+),(\d+),(\d+)-->/gm;
 export const OBSIDIAN_TAG_AT_STARTOFLINE_REGEX = /^#[^\s#]+/gi;
+
+// https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+block+in+a+note
+// Block identifiers can only consist of letters, numbers, and dashes.
+// RZ: 2024-01-01 Empirically determined that obsidian only recognizes a block identifier if the
+// "^" is preceded by a space
+export const OBSIDIAN_BLOCK_ID_ENDOFLINE_REGEX = / (\^[a-zA-Z0-9-]+)$/;
+
 export const PREFERRED_DATE_FORMAT = "YYYY-MM-DD";
 export const ALLOWED_DATE_FORMATS = [PREFERRED_DATE_FORMAT, "DD-MM-YYYY", "ddd MMM DD YYYY"];
 


Test patch:
diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts
index b02754f..8893dc3 100644
--- a/tests/unit/NoteQuestionParser.test.ts
+++ b/tests/unit/NoteQuestionParser.test.ts
@@ -29,7 +29,7 @@ test("No questions in the text", async () => {
     );
 });
 
-describe("Single question in the text", () => {
+describe("Single question in the text (without block identifier)", () => {
     test("SingleLineBasic: No schedule info", async () => {
         let noteText: string = `
 A::B
@@ -101,6 +101,155 @@ A::B
     });
 });
 
+describe("Single question in the text (with block identifier)", () => {
+    test("SingleLineBasic: No schedule info", async () => {
+        let noteText: string = `
+A::B ^d7cee0
+`;
+        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 ^d7cee0`,
+                    actualQuestion: "A::B",
+                    obsidianBlockId: "^d7cee0",
+                },
+
+                lineNo: 1,
+                hasEditLaterTag: false,
+                cards: [card1],
+                hasChanged: false,
+            },
+        ];
+        expect(
+            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath),
+        ).toMatchObject(expected);
+    });
+
+    test("SingleLineBasic: With schedule info (next line)", async () => {
+        let noteText: string = `#flashcards/test
+A::B ^d7cee0
+<!--SR:!2023-09-03,1,230-->
+    `;
+        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 ^d7cee0
+<!--SR:!2023-09-03,1,230-->`,
+                    actualQuestion: "A::B",
+                    textHash: "1c6b0b01215dc4",
+                    obsidianBlockId: "^d7cee0",
+                },
+                lineNo: 1,
+                hasEditLaterTag: false,
+                cards: [card1],
+                hasChanged: false,
+            },
+        ];
+        expect(
+            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath),
+        ).toMatchObject(expected);
+    });
+
+    test("SingleLineBasic: With schedule info (same line)", async () => {
+        let noteText: string = `#flashcards/test
+A::B <!--SR:!2023-09-03,1,230--> ^d7cee0
+    `;
+        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 <!--SR:!2023-09-03,1,230--> ^d7cee0`,
+                    actualQuestion: "A::B",
+                    textHash: "1c6b0b01215dc4",
+                    obsidianBlockId: "^d7cee0",
+                },
+                lineNo: 1,
+                hasEditLaterTag: false,
+                cards: [card1],
+                hasChanged: false,
+            },
+        ];
+        expect(
+            await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath),
+        ).toMatchObject(expected);
+    });
+
+    test("SingleLineBasic: With topic tag and schedule info (same line)", async () => {
+        let noteText: string = `
+#flashcards/test A::B <!--SR:!2023-09-03,1,230--> ^d7cee0
+    `;
+        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: `#flashcards/test A::B <!--SR:!2023-09-03,1,230--> ^d7cee0`,
+                    actualQuestion: "A::B",
+                    obsidianBlockId: "^d7cee0",
+                },
+                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
diff --git a/tests/unit/Question.test.ts b/tests/unit/Question.test.ts
index e9b901e..8ba2928 100644
--- a/tests/unit/Question.test.ts
+++ b/tests/unit/Question.test.ts
@@ -13,7 +13,7 @@ describe("Question", () => {
                 "```\nprint('Hello World!')\nprint('Howdy?')\nlambda x: x[0]\n```";
 
             let question: Question = new Question({
-                questionText: new QuestionText(text, null, text),
+                questionText: new QuestionText(text, null, text, null),
             });
 
             expect(question.getHtmlCommentSeparator(DEFAULT_SETTINGS)).toEqual("\n");
@@ -24,7 +24,7 @@ describe("Question", () => {
             let text: string = "Q1::A1";
 
             let question: Question = new Question({
-                questionText: new QuestionText(text, null, text),
+                questionText: new QuestionText(text, null, text, null),
             });
 
             expect(question.getHtmlCommentSeparator(DEFAULT_SETTINGS)).toEqual("\n");