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

Issue:
[BUG] Malformed card text during review, when multi-line card has space on Q/A separator line
**Describe the bug**
A multi line card get displayed without any text and screw up next card. When there is a leading space.

**To Reproduce**
Steps to reproduce the behavior:

1. Create a multi line card with a space in front of the separator
2. Go to review 
3. See empty card/screw card

**Expected behavior**
Should Ignore leading space in the separator line. 

**Screenshots**
![image](https://github.com/st3v3nmw/obsidian-spaced-repetition/assets/38220764/49226e85-ac2c-4e1d-9e7d-2ddc422c55bc)
![image](https://github.com/st3v3nmw/obsidian-spaced-repetition/assets/38220764/ad9b412c-7748-41c6-bd68-060f6d33ef29)
![image](https://github.com/st3v3nmw/obsidian-spaced-repetition/assets/38220764/43caebb3-2f29-4c95-a268-f99fa83e17af)

**Versions:**

-   OS: Win10
-   Obsidian version: v1.5.3
-   Plugin version: 1.11.0
-   Installer version: 5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.16 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36

Pull request description:
Fixes card text during flashcard review - case when multi-line card has space on Q/A separator line
Fixes #853

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index b344809..5b57e73 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,8 +6,18 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
 
 #### [Unreleased]
 
+-   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
+
+#### [Unreleased]
+
 -   Fixes bug #815 HTML review comment deactivates block identifier https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/815
 
+##### Changed
+
+-   Improved how spacing between the buttons was done
+-   Centered the button groups, which wheren't centered
+-   Fixed a hack in the response of the cram card mode
+
 #### [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/QuestionType.ts b/src/QuestionType.ts
index 0a65d8d..175ce94 100644
--- a/src/QuestionType.ts
+++ b/src/QuestionType.ts
@@ -1,5 +1,6 @@
 import { CardType } from "./Question";
 import { SRSettings } from "./settings";
+import { findLineIndexOfSearchStringIgnoringWs } from "./util/utils";
 
 export class CardFrontBack {
     front: string;
@@ -56,23 +57,30 @@ class QuestionType_SingleLineReversed implements IQuestionTypeHandler {
 
 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),
+        // We don't need to worry about "\r\n", as multi line questions processed by parse() concatenates lines explicitly with "\n"
+        const questionLines = questionText.split("\n");
+        const lineIdx = findLineIndexOfSearchStringIgnoringWs(
+            questionLines,
+            settings.multilineCardSeparator,
         );
-        const result: CardFrontBack[] = [item];
+        const side1: string = questionLines.slice(0, lineIdx).join("\n");
+        const side2: string = questionLines.slice(lineIdx + 1).join("\n");
+
+        const result: CardFrontBack[] = [new CardFrontBack(side1, side2)];
         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,
-            );
+        // We don't need to worry about "\r\n", as multi line questions processed by parse() concatenates lines explicitly with "\n"
+        const questionLines = questionText.split("\n");
+        const lineIdx = findLineIndexOfSearchStringIgnoringWs(
+            questionLines,
+            settings.multilineReversedCardSeparator,
+        );
+        const side1: string = questionLines.slice(0, lineIdx).join("\n");
+        const side2: string = questionLines.slice(lineIdx + 1).join("\n");
 
         const result: CardFrontBack[] = [
             new CardFrontBack(side1, side2),
diff --git a/src/util/utils.ts b/src/util/utils.ts
index 6517c83..8fc2717 100644
--- a/src/util/utils.ts
+++ b/src/util/utils.ts
@@ -102,3 +102,23 @@ export function stringTrimStart(str: string): [string, string] {
     const ws: string = str.substring(0, wsCount);
     return [ws, trimmed];
 }
+
+//
+// Returns the index of the line that consists of the search string.
+//
+// A line is considered a match if it is identical to the search string, (ignoring leading and
+// trailing spaces of the line)
+//
+export function findLineIndexOfSearchStringIgnoringWs(
+    lines: string[],
+    searchString: string,
+): number {
+    let result: number = -1;
+    for (let i = 0; i < lines.length; i++) {
+        if (lines[i].trim() == searchString) {
+            result = i;
+            break;
+        }
+    }
+    return result;
+}
diff --git a/styles.css b/styles.css
index 537d1ee..60c8ad5 100644
--- a/styles.css
+++ b/styles.css
@@ -1,15 +1,14 @@
 .sr-flashcard-menu {
-    display: block;
     width: 100%;
     display: flex;
     justify-content: center;
     align-items: center;
     flex-direction: row;
+    gap: 1rem;
 }
 
 .sr-flashcard-menu-item {
     box-shadow: none !important;
-    margin-right: 1rem;
     cursor: pointer;
 }
 
@@ -40,19 +39,18 @@
 .sr-flashcard-response {
     display: inline-grid;
     width: 100%;
-    grid-template-columns: auto auto auto auto;
+    grid-template-columns: auto auto auto;
     position: absolute;
     bottom: 0;
 }
 
 .sr-ignorestats-btn {
-    /* Bit of a hack, but it works. */
-    margin: 0px !important;
-    margin-right: 12px !important;
+    width: 100%;
 }
 
 .sr-ignorestats-response {
-    grid-template-columns: auto auto !important;
+    grid-template-columns: auto auto;
+    gap: var(--size-4-4);
 }
 
 .sr-centered {


Test patch:
diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts
index 8893dc3..f012601 100644
--- a/tests/unit/NoteQuestionParser.test.ts
+++ b/tests/unit/NoteQuestionParser.test.ts
@@ -283,6 +283,53 @@ Q3::A3
         expect(questionList[1].topicPath).toEqual(new TopicPath(["flashcards", "science"]));
         expect(questionList[2].topicPath).toEqual(new TopicPath(["flashcards", "science"]));
     });
+
+    test("MultiLine: Space before multi line separator", async () => {
+        // https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/853
+        let noteText: string = `
+#flashcards/test/b853 
+
+Question::Answer
+
+Multiline question
+ ?
+Multiline answer
+
+Multiline question2
+ ??
+Multiline answer2
+ 
+`;
+        let noteFile: ISRFile = new UnitTestSRFile(noteText);
+
+        let questionList: Question[] = await parserWithDefaultSettings.createQuestionList(
+            noteFile,
+            TopicPath.emptyPath,
+        );
+        expect(questionList.length).toEqual(3);
+        expect(questionList[0].cards).toMatchObject([
+            {
+                front: "Question",
+                back: "Answer",
+            },
+        ]);
+        expect(questionList[1].cards).toMatchObject([
+            {
+                front: "Multiline question",
+                back: "Multiline answer",
+            },
+        ]);
+        expect(questionList[2].cards).toMatchObject([
+            {
+                front: "Multiline question2",
+                back: "Multiline answer2",
+            },
+            {
+                front: "Multiline answer2",
+                back: "Multiline question2",
+            },
+        ]);
+    });
 });
 
 describe("Handling tags within note", () => {
diff --git a/tests/unit/util/utils.test.ts b/tests/unit/util/utils.test.ts
index 49e0c51..36a797e 100644
--- a/tests/unit/util/utils.test.ts
+++ b/tests/unit/util/utils.test.ts
@@ -1,5 +1,5 @@
 import { YAML_FRONT_MATTER_REGEX } from "src/constants";
-import { literalStringReplace } from "src/util/utils";
+import { findLineIndexOfSearchStringIgnoringWs, literalStringReplace } from "src/util/utils";
 
 describe("literalStringReplace", () => {
     test("Replacement string doesn't have any dollar signs", async () => {
@@ -91,3 +91,68 @@ describe("YAML_FRONT_MATTER_REGEX", () => {
         expect(YAML_FRONT_MATTER_REGEX.test(text)).toEqual(true);
     });
 });
+
+describe("findLineIndexOfSearchStringIgnoringWs", () => {
+    const space: string = " ";
+    test("Search string not present", () => {
+        const lines: string[] = [
+            "A very boring multi-line question.",
+            "(With this extra info, not so boring after all)",
+            "?",
+            "A very boring multi-line answer.",
+            "(With this extra info, not so boring after all)",
+        ];
+        expect(findLineIndexOfSearchStringIgnoringWs(lines, "??")).toEqual(-1);
+    });
+
+    test("Search string present, but only on a line with other text", () => {
+        const lines: string[] = [
+            "What do you think of this multi-line question?",
+            "??",
+            "A very boring multi-line answer.",
+            "(With this extra info, not so boring after all)",
+        ];
+        expect(findLineIndexOfSearchStringIgnoringWs(lines, "?")).toEqual(-1);
+    });
+
+    test("Search string found at start of text (exactly)", () => {
+        const lines: string[] = [
+            "?",
+            "A very boring multi-line answer.",
+            "(With this extra info, not so boring after all)",
+        ];
+        expect(findLineIndexOfSearchStringIgnoringWs(lines, "?")).toEqual(0);
+    });
+
+    test("Search line found at start of text (text has whitespace)", () => {
+        const lines: string[] = [
+            `${space}?${space}`,
+            "A very boring multi-line answer.",
+            "(With this extra info, not so boring after all)",
+        ];
+        expect(findLineIndexOfSearchStringIgnoringWs(lines, "?")).toEqual(0);
+    });
+
+    test("Search line found in middle line of text (exactly)", () => {
+        const lines: string[] = [
+            "What do you think of this multi-line question?",
+            "(With this extra info, not so boring after all)",
+            "??",
+            "A very boring multi-line answer.",
+            "(With this extra info, not so boring after all)",
+        ];
+        expect(findLineIndexOfSearchStringIgnoringWs(lines, "??")).toEqual(2);
+    });
+
+    test("Search line found in middle line of text (text has whitespace)", () => {
+        const lines: string[] = [
+            "What do you think of this multi-line question?",
+            "(With this extra info, not so boring after all)",
+            `${space}??`,
+            "A very boring multi-line answer.",
+            "(With this extra info, not so boring after all)",
+        ];
+
+        expect(findLineIndexOfSearchStringIgnoringWs(lines, "??")).toEqual(2);
+    });
+});