From 0cdcc6266ec206f68b9f9ebdfa35cb32700b262f Mon Sep 17 00:00:00 2001 From: ronzulu <75528127+ronzulu@users.noreply.github.com> Date: Tue, 2 Apr 2024 14:01:38 +1100 Subject: [PATCH 1/5] Fixed and ready for beta testing --- src/NoteQuestionParser.ts | 55 +++++++++---- src/SRFile.ts | 12 +-- src/main.ts | 1 + src/util/utils.ts | 20 ++++- tests/unit/NoteQuestionParser.test.ts | 112 +++++++++++++++++++++++++- tests/unit/helpers/UnitTestHelper.ts | 3 +- tests/unit/util/utils.test.ts | 22 ++++- 7 files changed, 195 insertions(+), 30 deletions(-) diff --git a/src/NoteQuestionParser.ts b/src/NoteQuestionParser.ts index f5f197c5..9612c045 100644 --- a/src/NoteQuestionParser.ts +++ b/src/NoteQuestionParser.ts @@ -5,7 +5,7 @@ import { parseEx, ParsedQuestionInfo } from "./parser"; import { Question, QuestionText } from "./Question"; import { CardFrontBack, CardFrontBackUtil } from "./QuestionType"; import { SRSettings, SettingsUtil } from "./settings"; -import { ISRFile } from "./SRFile"; +import { ISRFile, frontmatterTagPseudoLineNum } from "./SRFile"; import { TopicPath, TopicPathList } from "./TopicPath"; import { extractFrontmatter, splitTextIntoLineArray } from "./util/utils"; @@ -14,6 +14,10 @@ export class NoteQuestionParser { noteFile: ISRFile; folderTopicPath: TopicPath; noteText: string; + frontmatterText: string; + + // This is the note text, but with the frontmatter blanked out (see extractFrontmatter for reasoning) + contentText: string; noteLines: string[]; tagCacheList: TagCache[]; frontmatterTopicPathList: TopicPathList; @@ -39,8 +43,10 @@ export class NoteQuestionParser { tagCacheList.some((item) => SettingsUtil.isFlashcardTag(this.settings, item.tag)) || folderTopicPath.hasPath; if (hasTopicPaths) { + // The following analysis can require fair computation. // There is no point doing it if there aren't any topic paths + [this.frontmatterText, this.contentText] = extractFrontmatter(noteText); // Create the question list this.questionList = this.doCreateQuestionList( @@ -108,9 +114,10 @@ export class NoteQuestionParser { } private parseQuestions(): ParsedQuestionInfo[] { + // We pass contentText which has the frontmatter blanked out; see extractFrontmatter for reasoning const settings: SRSettings = this.settings; const result: ParsedQuestionInfo[] = parseEx( - this.noteText, + this.contentText, settings.singleLineCardSeparator, settings.singleLineReversedCardSeparator, settings.multilineCardSeparator, @@ -177,35 +184,30 @@ export class NoteQuestionParser { // Only keep tags that are: // 1. specified in the user settings as flashcardTags, and - // 2. is not question specific (determined by line number) - const filteredTagCacheList: TagCache[] = tagCacheList.filter( - (item) => - SettingsUtil.isFlashcardTag(this.settings, item.tag) && - this.questionList.every( - (q) => !q.parsedQuestionInfo.isQuestionLineNum(item.position.start.line), - ), + // 2. is not question specific (determined by line number) - i.e. is "note level" + const noteLevelTagList: TagCache[] = tagCacheList.filter( + (item) => this.isNoteLevelFlashcardTag(item) ); let frontmatterLineCount: number = null; - if (filteredTagCacheList.length > 0) { + if (noteLevelTagList.length > 0) { // To simplify analysis, ensure that the supplied list is ordered by line number - filteredTagCacheList.sort((a, b) => a.position.start.line - b.position.start.line); + noteLevelTagList.sort((a, b) => a.position.start.line - b.position.start.line); // Treat the frontmatter slightly differently (all tags grouped together even if on separate lines) - const [frontmatter, _] = extractFrontmatter(this.noteText); - if (frontmatter) { - frontmatterLineCount = splitTextIntoLineArray(frontmatter).length; - const frontmatterTagCacheList = filteredTagCacheList.filter( + if (this.frontmatterText) { + frontmatterLineCount = splitTextIntoLineArray(this.frontmatterText).length; + const frontmatterTagCacheList = noteLevelTagList.filter( (item) => item.position.start.line < frontmatterLineCount, ); // Doesn't matter what line number we specify, as long as it's less than frontmatterLineCount if (frontmatterTagCacheList.length > 0) - frontmatterTopicPathList = this.createTopicPathList(frontmatterTagCacheList, 0); + frontmatterTopicPathList = this.createTopicPathList(frontmatterTagCacheList, frontmatterTagPseudoLineNum); } } // const contentStartLineNum: number = frontmatterLineCount > 0 ? frontmatterLineCount + 1 : 0; - const contentTagCacheList: TagCache[] = filteredTagCacheList.filter( + const contentTagCacheList: TagCache[] = noteLevelTagList.filter( (item) => item.position.start.line >= contentStartLineNum, ); @@ -228,6 +230,25 @@ export class NoteQuestionParser { return [frontmatterTopicPathList, contentTopicPathList]; } + private isNoteLevelFlashcardTag(tagItem: TagCache): boolean { + // The tag (e.g. "#flashcards") must be a valid flashcard tag as per the user settings + if (!SettingsUtil.isFlashcardTag(this.settings, tagItem.tag)) { + return false; + } + + // If the tag is defined in the frontmatter, then it is a "note level" tag + const tagLineNum: number = tagItem.position.start.line; + if (tagLineNum == frontmatterTagPseudoLineNum) { + return true; + } + + // Check that the tag is not question specific (determined by line number) + const isQuestionSpecific: boolean = this.questionList.some( + (q) => q.parsedQuestionInfo.isQuestionLineNum(tagLineNum) + ); + return !isQuestionSpecific; + } + private createTopicPathList(tagCacheList: TagCache[], lineNum: number): TopicPathList { const list: TopicPath[] = [] as TopicPath[]; for (const tagCache of tagCacheList) { diff --git a/src/SRFile.ts b/src/SRFile.ts index bc18b10a..106f7336 100644 --- a/src/SRFile.ts +++ b/src/SRFile.ts @@ -10,6 +10,10 @@ export interface ISRFile { write(content: string): Promise; } +// The Obsidian frontmatter cache doesn't include the line number for the specific tag. +// We define as -1 so that we can differentiate tags within the frontmatter and tags within the content +export const frontmatterTagPseudoLineNum: number = -1; + export class SrTFile implements ISRFile { file: TFile; vault: Vault; @@ -48,10 +52,6 @@ export class SrTFile implements ISRFile { const result: TagCache[] = [] as TagCache[]; const frontmatterTags: string = frontmatter != null ? frontmatter["tags"] + "" : null; if (frontmatterTags) { - // The frontmatter doesn't include the line number for the specific tag, defining as line 1 is good enough. - // (determineQuestionTopicPathList() only needs to know that these frontmatter tags come before all others - // in the file) - const line: number = 1; // Parse the frontmatter tag string into a list, each entry including the leading "#" const tagStrList: string[] = parseObsidianFrontmatterTag(frontmatterTags); @@ -59,8 +59,8 @@ export class SrTFile implements ISRFile { const tag: TagCache = { tag: str, position: { - start: { line: line, col: null, offset: null }, - end: { line: line, col: null, offset: null }, + start: { line: frontmatterTagPseudoLineNum, col: null, offset: null }, + end: { line: frontmatterTagPseudoLineNum, col: null, offset: null }, }, }; result.push(tag); diff --git a/src/main.ts b/src/main.ts index a8c3c278..6c476837 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,6 +91,7 @@ export default class SRPlugin extends Plugin { async onload(): Promise { await this.loadPluginData(); + console.log(`OSR: onload(): bug-922-923-card-after-frontmatter: v1`); this.easeByPath = new NoteEaseList(this.data.settings); this.questionPostponementList = new QuestionPostponementList( this, diff --git a/src/util/utils.ts b/src/util/utils.ts index ee7974b3..c55c6bb2 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -96,6 +96,18 @@ export function stringTrimStart(str: string): [string, string] { return [ws, trimmed]; } +// +// 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) +// export function extractFrontmatter(str: string): [string, string] { let frontmatter: string = ""; let content: string = ""; @@ -115,10 +127,14 @@ export function extractFrontmatter(str: string): [string, string] { const frontmatterStartLineNum: number = 0; const frontmatterLineCount: number = frontmatterEndLineNum - frontmatterStartLineNum + 1; - const frontmatterLines: string[] = lines.splice( + const frontmatterLines: string[] = []; /* [ ...lines].splice( frontmatterStartLineNum, frontmatterLineCount, - ); + ); */ + for (let i = 0; i <= frontmatterEndLineNum; i++) { + frontmatterLines.push(lines[i]); + lines[i] = ""; + } frontmatter = frontmatterLines.join("\n"); content = lines.join("\n"); } diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts index eb63a9be..b94a5692 100644 --- a/tests/unit/NoteQuestionParser.test.ts +++ b/tests/unit/NoteQuestionParser.test.ts @@ -5,9 +5,10 @@ import { CardType, Question } from "src/Question"; import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; import { TopicPath, TopicPathList } from "src/TopicPath"; import { createTest_NoteQuestionParser } from "./SampleItems"; -import { ISRFile } from "src/SRFile"; +import { ISRFile, frontmatterTagPseudoLineNum } from "src/SRFile"; import { setupStaticDateProvider_20230906 } from "src/util/DateProvider"; import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; +import { Card } from "src/Card"; let parserWithDefaultSettings: NoteQuestionParser = createTest_NoteQuestionParser(DEFAULT_SETTINGS); let settings_ConvertFoldersToDecks: SRSettings = { ...DEFAULT_SETTINGS }; @@ -155,7 +156,7 @@ In computer-science, a *heap* is a tree-based data-structure, that satisfies the { questionType: CardType.MultiLineBasic, // Explicitly checking that #data-structure and #2024/03-11 are not included - topicPathList: TopicPathList.fromPsv("#flashcards", 0), + topicPathList: TopicPathList.fromPsv("#flashcards", frontmatterTagPseudoLineNum), }, ]; expect( @@ -617,6 +618,113 @@ Q1::A1 }); }); +describe("Questions immediately after closing line of frontmatter", () => { + // The frontmatter should be discarded + // (only the specified question text should be used) + test("Multi-line with question", async () => { + let noteText: string = `--- +created: 2024-03-11 10:41 +tags: + - flashcards + - data-structure +--- +**What is a Heap?** +? +In computer-science, a *heap* is a tree-based data-structure, that satisfies the *heap property*. A heap is a complete *binary-tree*! +`; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let expected = [ + { + questionType: CardType.MultiLineBasic, + // Explicitly checking that #data-structure is not included + topicPathList: TopicPathList.fromPsv("#flashcards", frontmatterTagPseudoLineNum), + cards: [ + new Card({ + front: `**What is a Heap?**`, + back: "In computer-science, a *heap* is a tree-based data-structure, that satisfies the *heap property*. A heap is a complete *binary-tree*!" + }) + ] + }, + ]; + expect( + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + ).toMatchObject(expected); + }); + + test("Multi-line without question (i.e. question is blank)", async () => { + let noteText: string = `--- +created: 2024-03-11 10:41 +tags: + - flashcards + - data-structure +--- +? +In computer-science, a *heap* is a tree-based data-structure, that satisfies the *heap property*. A heap is a complete *binary-tree*! + `; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let expected = [ + { + questionType: CardType.MultiLineBasic, + // Explicitly checking that #data-structure is not included + topicPathList: TopicPathList.fromPsv("#flashcards", frontmatterTagPseudoLineNum), + cards: [ + new Card({ + front: "", + back: "In computer-science, a *heap* is a tree-based data-structure, that satisfies the *heap property*. A heap is a complete *binary-tree*!" + }) + ] + }, + ]; + expect( + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + ).toMatchObject(expected); + }); + + test("single-line question", async () => { + let noteText: string = `--- +created: 2024-03-11 10:41 +tags: + - flashcards + - data-structure +--- +In computer-science, a *heap* is::a tree-based data-structure +A::B + `; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let expected = [ + { + questionType: CardType.SingleLineBasic, + topicPathList: TopicPathList.fromPsv("#flashcards", frontmatterTagPseudoLineNum), + cards: [ + new Card({ + front: "In computer-science, a *heap* is", + back: "a tree-based data-structure" + }) + ] + }, + { + questionType: CardType.SingleLineBasic, + topicPathList: TopicPathList.fromPsv("#flashcards", frontmatterTagPseudoLineNum), + cards: [ + new Card({ + front: "A", + back: "B" + }) + ] + }, + ]; + expect( + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + ).toMatchObject(expected); + }); +}); + function checkQuestion1(question: Question) { expect(question.cards.length).toEqual(1); let card1 = { diff --git a/tests/unit/helpers/UnitTestHelper.ts b/tests/unit/helpers/UnitTestHelper.ts index e87c8420..127e1000 100644 --- a/tests/unit/helpers/UnitTestHelper.ts +++ b/tests/unit/helpers/UnitTestHelper.ts @@ -1,4 +1,5 @@ import { TagCache } from "obsidian"; +import { frontmatterTagPseudoLineNum } from "src/SRFile"; import { extractFrontmatter, splitTextIntoLineArray } from "src/util/utils"; export function unitTest_CreateTagCache(tag: string, lineNum: number): TagCache { @@ -25,7 +26,7 @@ export function unitTest_GetAllTagsFromTextEx(text: string): TagCache[] { if (foundTagHeading) { if (line.startsWith(dataPrefix)) { const tagStr: string = line.substring(dataPrefix.length); - result.push(unitTest_CreateTagCache("#" + tagStr, i)); + result.push(unitTest_CreateTagCache("#" + tagStr, frontmatterTagPseudoLineNum)); } else { break; } diff --git a/tests/unit/util/utils.test.ts b/tests/unit/util/utils.test.ts index b4b79e62..bcfcf464 100644 --- a/tests/unit/util/utils.test.ts +++ b/tests/unit/util/utils.test.ts @@ -126,7 +126,15 @@ tags: let content: string; [frontmatter, content] = extractFrontmatter(text); expect(frontmatter).toEqual(text); - expect(content).toEqual(""); + const frontmatterBlankedOut: string = ` + + + + + + +`; + expect(content).toEqual(frontmatterBlankedOut); }); test("With frontmatter (and content)", () => { @@ -157,7 +165,17 @@ ${content}`; const [f, c] = extractFrontmatter(text); expect(f).toEqual(frontmatter); - expect(c).toEqual(content); + const frontmatterBlankedOut: string = ` + + + + + + +`; + const expectedContent: string = `${frontmatterBlankedOut} +${content}`; + expect(c).toEqual(expectedContent); }); }); From 4c8ad909ba52156ccbbf5adf0917f405ac848237 Mon Sep 17 00:00:00 2001 From: ronzulu <75528127+ronzulu@users.noreply.github.com> Date: Wed, 3 Apr 2024 09:55:09 +1100 Subject: [PATCH 2/5] Fixed for case where topic tag is at the end of the question --- src/NoteQuestionParser.ts | 102 ++++++++++++++++---------- src/SRFile.ts | 4 + src/main.ts | 2 +- tests/unit/NoteQuestionParser.test.ts | 69 +++++++++++++++++ 4 files changed, 139 insertions(+), 38 deletions(-) diff --git a/src/NoteQuestionParser.ts b/src/NoteQuestionParser.ts index 9612c045..b69bc0f2 100644 --- a/src/NoteQuestionParser.ts +++ b/src/NoteQuestionParser.ts @@ -19,9 +19,19 @@ export class NoteQuestionParser { // This is the note text, but with the frontmatter blanked out (see extractFrontmatter for reasoning) contentText: string; noteLines: string[]; + + // Complete list of tags tagCacheList: TagCache[]; + + // tagCacheList filtered to those specified in the user settings (e.g. "#flashcards") + flashcardTagList: TagCache[]; + + // flashcardTagList filtered to those within the frontmatter frontmatterTopicPathList: TopicPathList; + + // flashcardTagList filtered to those within the note's content and are note-level tags (i.e. not question specific) contentTopicPathInfo: TopicPathList[]; + questionList: Question[]; constructor(settings: SRSettings) { @@ -179,68 +189,80 @@ export class NoteQuestionParser { // within frontmatter appear on separate lines) // private analyseTagCacheList(tagCacheList: TagCache[]): [TopicPathList, TopicPathList[]] { - let frontmatterTopicPathList: TopicPathList = null; - const contentTopicPathList: TopicPathList[] = [] as TopicPathList[]; - // Only keep tags that are: + // The tag (e.g. "#flashcards") must be a valid flashcard tag as per the user settings + this.flashcardTagList = tagCacheList.filter( + (item) => SettingsUtil.isFlashcardTag(this.settings, item.tag) + ); + if (this.flashcardTagList.length > 0) { + // To simplify analysis, sort the flashcard list ordered by line number + this.flashcardTagList.sort((a, b) => a.position.start.line - b.position.start.line); + } + + let frontmatterLineCount: number = 0; + if (this.frontmatterText) { + frontmatterLineCount = splitTextIntoLineArray(this.frontmatterText).length; + } + + const frontmatterTopicPathList: TopicPathList = this.determineFrontmatterTopicPathList(this.flashcardTagList, frontmatterLineCount); + const contentTopicPathList: TopicPathList[] = this.determineContentTopicPathList(this.flashcardTagList, frontmatterLineCount); + + return [frontmatterTopicPathList, contentTopicPathList]; + } + + private determineFrontmatterTopicPathList(flashcardTagList: TagCache[], frontmatterLineCount: number): TopicPathList { + let result: TopicPathList = null; + + // Filter for tags that are: // 1. specified in the user settings as flashcardTags, and // 2. is not question specific (determined by line number) - i.e. is "note level" - const noteLevelTagList: TagCache[] = tagCacheList.filter( - (item) => this.isNoteLevelFlashcardTag(item) + const noteLevelTagList: TagCache[] = flashcardTagList.filter( + (item) => (item.position.start.line == frontmatterTagPseudoLineNum) && this.isNoteLevelFlashcardTag(item) ); - let frontmatterLineCount: number = null; if (noteLevelTagList.length > 0) { - // To simplify analysis, ensure that the supplied list is ordered by line number - noteLevelTagList.sort((a, b) => a.position.start.line - b.position.start.line); - // Treat the frontmatter slightly differently (all tags grouped together even if on separate lines) if (this.frontmatterText) { - frontmatterLineCount = splitTextIntoLineArray(this.frontmatterText).length; const frontmatterTagCacheList = noteLevelTagList.filter( (item) => item.position.start.line < frontmatterLineCount, ); - // Doesn't matter what line number we specify, as long as it's less than frontmatterLineCount if (frontmatterTagCacheList.length > 0) - frontmatterTopicPathList = this.createTopicPathList(frontmatterTagCacheList, frontmatterTagPseudoLineNum); + result = this.createTopicPathList(frontmatterTagCacheList, frontmatterTagPseudoLineNum); } } - // - const contentStartLineNum: number = frontmatterLineCount > 0 ? frontmatterLineCount + 1 : 0; - const contentTagCacheList: TagCache[] = noteLevelTagList.filter( - (item) => item.position.start.line >= contentStartLineNum, + return result; + } + + private determineContentTopicPathList(flashcardTagList: TagCache[], frontmatterLineCount: number): TopicPathList[] { + const result: TopicPathList[] = [] as TopicPathList[]; + + // NOTE: Line numbers are zero based, therefore don't add 1 to frontmatterLineCount to get contentStartLineNum + const contentStartLineNum: number = frontmatterLineCount; + const contentTagCacheList: TagCache[] = flashcardTagList.filter( + (item) => (item.position.start.line >= contentStartLineNum) && this.isNoteLevelFlashcardTag(item), ); + // We group together all tags that are on the same line, taking advantage of flashcardTagList being ordered by line number let list: TagCache[] = [] as TagCache[]; - for (const t of contentTagCacheList) { + for (const tag of contentTagCacheList) { if (list.length != 0) { const startLineNum: number = list[0].position.start.line; - if (startLineNum != t.position.start.line) { - contentTopicPathList.push(this.createTopicPathList(list, startLineNum)); + if (startLineNum != tag.position.start.line) { + result.push(this.createTopicPathList(list, startLineNum)); list = [] as TagCache[]; } } - list.push(t); + list.push(tag); } if (list.length > 0) { const startLineNum: number = list[0].position.start.line; - contentTopicPathList.push(this.createTopicPathList(list, startLineNum)); + result.push(this.createTopicPathList(list, startLineNum)); } - - return [frontmatterTopicPathList, contentTopicPathList]; + return result; } private isNoteLevelFlashcardTag(tagItem: TagCache): boolean { - // The tag (e.g. "#flashcards") must be a valid flashcard tag as per the user settings - if (!SettingsUtil.isFlashcardTag(this.settings, tagItem.tag)) { - return false; - } - - // If the tag is defined in the frontmatter, then it is a "note level" tag const tagLineNum: number = tagItem.position.start.line; - if (tagLineNum == frontmatterTagPseudoLineNum) { - return true; - } // Check that the tag is not question specific (determined by line number) const isQuestionSpecific: boolean = this.questionList.some( @@ -257,6 +279,11 @@ export class NoteQuestionParser { return new TopicPathList(list, lineNum); } + private createTopicPathList_FromSingleTag(tagCache: TagCache): TopicPathList { + const list: TopicPath[] = [TopicPath.getTopicPathFromTag(tagCache.tag)]; + return new TopicPathList(list, tagCache.position.start.line); + } + // // A question can be associated with multiple topics (hence returning TopicPathList and not just TopicPath). // @@ -283,17 +310,18 @@ export class NoteQuestionParser { // Find the last TopicPathList prior to the question (in the order present in the file) for (let i = this.contentTopicPathInfo.length - 1; i >= 0; i--) { - const info: TopicPathList = this.contentTopicPathInfo[i]; - if (info.lineNum < question.parsedQuestionInfo.firstLineNum) { - result = info; + const topicPathList: TopicPathList = this.contentTopicPathInfo[i]; + if (topicPathList.lineNum < question.parsedQuestionInfo.firstLineNum) { + result = topicPathList; break; } } // For backward compatibility with functionality pre https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/495: // if nothing matched, then use the first one - if (!result && this.contentTopicPathInfo.length > 0) { - result = this.contentTopicPathInfo[0]; + // This could occur if the only topic tags present are question specific + if (!result && this.flashcardTagList.length > 0) { + result = this.createTopicPathList_FromSingleTag(this.flashcardTagList[0]); } } } diff --git a/src/SRFile.ts b/src/SRFile.ts index 106f7336..e67a07e2 100644 --- a/src/SRFile.ts +++ b/src/SRFile.ts @@ -1,6 +1,7 @@ import { MetadataCache, TFile, Vault, HeadingCache, TagCache, FrontMatterCache } from "obsidian"; import { parseObsidianFrontmatterTag } from "./util/utils"; +// NOTE: Line numbers are zero based export interface ISRFile { get path(): string; get basename(): string; @@ -14,6 +15,7 @@ export interface ISRFile { // We define as -1 so that we can differentiate tags within the frontmatter and tags within the content export const frontmatterTagPseudoLineNum: number = -1; +// NOTE: Line numbers are zero based export class SrTFile implements ISRFile { file: TFile; vault: Vault; @@ -37,6 +39,7 @@ export class SrTFile implements ISRFile { const result: TagCache[] = [] as TagCache[]; const fileCachedData = this.metadataCache.getFileCache(this.file) || {}; if (fileCachedData.tags?.length > 0) { + // console.log(`getAllTagsFromText: tags: ${fileCachedData.tags.map((item) => `(${item.position.start.line}: ${item.tag})`).join("|")}`); result.push(...fileCachedData.tags); } @@ -72,6 +75,7 @@ export class SrTFile implements ISRFile { getQuestionContext(cardLine: number): string[] { const fileCachedData = this.metadataCache.getFileCache(this.file) || {}; const headings: HeadingCache[] = fileCachedData.headings || []; + // console.log(`getQuestionContext: headings: ${headings.map((item) => `(${item.position.start.line}: ${item.heading})`).join("|")}`); const stack: HeadingCache[] = []; for (const heading of headings) { if (heading.position.start.line > cardLine) { diff --git a/src/main.ts b/src/main.ts index 6c476837..fb82ec9c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,7 +91,7 @@ export default class SRPlugin extends Plugin { async onload(): Promise { await this.loadPluginData(); - console.log(`OSR: onload(): bug-922-923-card-after-frontmatter: v1`); + console.log(`OSR: onload(): bug-915-922-923-missing-cards: v2`); this.easeByPath = new NoteEaseList(this.data.settings); this.questionPostponementList = new QuestionPostponementList( this, diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts index b94a5692..b6a2ff97 100644 --- a/tests/unit/NoteQuestionParser.test.ts +++ b/tests/unit/NoteQuestionParser.test.ts @@ -530,6 +530,38 @@ Q1::A1 expect(questionList[2].topicPathList.formatPsv()).toEqual(expectedPath); }); + // https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/915#issuecomment-2017508391 + test("Topic tag on first line after frontmatter", async () => { + let noteText: string = `--- +created: 2023-10-26T07:34 +--- +#flashcards/English + +## taunting & teasing & irony & sarcasm + +Stop trying ==to milk the crowd== for sympathy. // доить толпу +`; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let expectedPath: string = "#flashcards/English"; + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let expected = [ + { + questionType: CardType.Cloze, + topicPathList: TopicPathList.fromPsv("#flashcards/English", 3), // #flashcards/English is on the 4th line, line number 3 + cards: [ + new Card({ + front: "Stop trying [...] for sympathy. // доить толпу", + back: `Stop trying to milk the crowd for sympathy. // доить толпу`, + }) + ] + }, + ]; + expect( + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + ).toMatchObject(expected); + }); + test("Topic tag within question overrides the note topic, for that topic only", async () => { let noteText: string = `#flashcards/test Q1::A1 @@ -615,6 +647,43 @@ Q1::A1 expect(questionList[0].cards.length).toEqual(1); expect(questionList[0].cards[0].front).toEqual("Q5"); }); + + // https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/915#issuecomment-2016580471 + test("Topic tag at end of line", async () => { + let noteText: string = `--- +Title: "The Taliban at war: 2001-2018" +Authors: "Antonio Giustozzi" +Year: 2019 +URL: +DOI: +Unique Citekey: Talibanwar20012018 +Zotero Link: zotero://select/items/@Talibanwar20012018 +--- +> [!PDF|255, 208, 0] [[The Taliban at War_ 2001 - 2018.pdf#page=10&annotation=1440R|The Taliban at War_ 2001 - 2018, page 10]] +> > The Taliban Emirate, established in 1996, was in 2001 overthrown relatively easily by a coalition of US forces and various Afghan anti-Taliban groups. Few at the end of 2001 expected to hear again from the Taliban, except in the annals of history. Even as signs emerged in 2003 of a Taliban comeback, in the shape of an insurgency against the post-2001 Afghan government and its international sponsors, many did not take it seriously. It was hard to imagine that the Taliban would be able to mount a resilient challenge to a large-scale commitment of forces by the US and its allies. + +What year was the Taliban Emirate founded?::1996 #flashcards +`; + let noteFile: ISRFile = new UnitTestSRFile(noteText); + + let folderTopicPath: TopicPath = TopicPath.emptyPath; + let expected = [ + { + questionType: CardType.SingleLineBasic, + topicPathList: TopicPathList.fromPsv("#flashcards", 12), + cards: [ + new Card({ + front: "What year was the Taliban Emirate founded?", + back: "1996 #flashcards" + }) + ] + }, + ]; + expect( + await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + ).toMatchObject(expected); + }); + }); }); From 3f77de2db18bc46d24294fa9cad166ef02424c35 Mon Sep 17 00:00:00 2001 From: ronzulu <75528127+ronzulu@users.noreply.github.com> Date: Thu, 4 Apr 2024 14:36:33 +1100 Subject: [PATCH 3/5] Updated comment --- src/parser.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/parser.ts b/src/parser.ts index 2e8e8c1f..5069369f 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -22,6 +22,13 @@ export class ParsedQuestionInfo { /** * Returns flashcards found in `text` + * + * It is best that the text does not contain frontmatter, see extractFrontmatter for reasoning + * + * Multi-line question with blank lines user workaround: + * As of 3/04/2024 there is no support for including blank lines within multi-line questions + * As a workaround, one user uses a zero width Unicode character - U+200B + * https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/915#issuecomment-2031003092 * * @param text - The text to extract flashcards from * @param singlelineCardSeparator - Separator for inline basic cards From 8749bff27d0be55b26a87cff6c5cddd485a27838 Mon Sep 17 00:00:00 2001 From: ronzulu <75528127+ronzulu@users.noreply.github.com> Date: Tue, 9 Apr 2024 11:18:24 +1000 Subject: [PATCH 4/5] Comments, change log etc --- docs/changelog.md | 6 ++++++ src/main.ts | 1 - src/util/utils.ts | 9 ++------- tests/unit/NoteQuestionParser.test.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 4d80a7d8..80ab1e5a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,12 @@ 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] + +- [BUG] Plugin not picking up certain flashcards https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/915 +- [BUG] Unable to recognize multi-line card that begins immediately after the frontmatter's closing line https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/922 +- [BUG] Most of my flashcards are now missing https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/923 + #### [1.12.2](https://github.com/st3v3nmw/obsidian-spaced-repetition/compare/1.12.1...1.12.2) - fix bug with recognizing frontmatter topic tags (led to missing cards shown for review) [`#920`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/920) diff --git a/src/main.ts b/src/main.ts index fb82ec9c..a8c3c278 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,7 +91,6 @@ export default class SRPlugin extends Plugin { async onload(): Promise { await this.loadPluginData(); - console.log(`OSR: onload(): bug-915-922-923-missing-cards: v2`); this.easeByPath = new NoteEaseList(this.data.settings); this.questionPostponementList = new QuestionPostponementList( this, diff --git a/src/util/utils.ts b/src/util/utils.ts index c55c6bb2..311fc501 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -125,13 +125,8 @@ export function extractFrontmatter(str: string): [string, string] { if (frontmatterEndLineNum) { const frontmatterStartLineNum: number = 0; - const frontmatterLineCount: number = - frontmatterEndLineNum - frontmatterStartLineNum + 1; - const frontmatterLines: string[] = []; /* [ ...lines].splice( - frontmatterStartLineNum, - frontmatterLineCount, - ); */ - for (let i = 0; i <= frontmatterEndLineNum; i++) { + const frontmatterLines: string[] = []; + for (let i = frontmatterStartLineNum; i <= frontmatterEndLineNum; i++) { frontmatterLines.push(lines[i]); lines[i] = ""; } diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts index b6a2ff97..25403271 100644 --- a/tests/unit/NoteQuestionParser.test.ts +++ b/tests/unit/NoteQuestionParser.test.ts @@ -649,7 +649,7 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу }); // https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/915#issuecomment-2016580471 - test("Topic tag at end of line", async () => { + test("Topic tag at end of line question line (no other tags present)", async () => { let noteText: string = `--- Title: "The Taliban at war: 2001-2018" Authors: "Antonio Giustozzi" From edcfac553ebd4b00eaf20ef7d66a5972dbe0fdff Mon Sep 17 00:00:00 2001 From: ronzulu <75528127+ronzulu@users.noreply.github.com> Date: Tue, 9 Apr 2024 11:34:52 +1000 Subject: [PATCH 5/5] lint and format --- CHANGELOG.md | 2 +- CONTRIBUTING.md | 2 +- src/NoteQuestionParser.ts | 45 +++++++++++++++-------- src/SRFile.ts | 1 - src/parser.ts | 4 +-- src/util/utils.ts | 10 +++--- tests/unit/NoteQuestionParser.test.ts | 51 +++++++++++++-------------- 7 files changed, 65 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bed66b3..1cc39a66 120000 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -docs/changelog.md \ No newline at end of file +docs/changelog.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 651dc17d..9815d5bd 120000 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1 @@ -docs/en/contributing.md \ No newline at end of file +docs/en/contributing.md diff --git a/src/NoteQuestionParser.ts b/src/NoteQuestionParser.ts index b69bc0f2..a3777d22 100644 --- a/src/NoteQuestionParser.ts +++ b/src/NoteQuestionParser.ts @@ -53,10 +53,9 @@ export class NoteQuestionParser { tagCacheList.some((item) => SettingsUtil.isFlashcardTag(this.settings, item.tag)) || folderTopicPath.hasPath; if (hasTopicPaths) { - // The following analysis can require fair computation. // There is no point doing it if there aren't any topic paths - [this.frontmatterText, this.contentText] = extractFrontmatter(noteText); + [this.frontmatterText, this.contentText] = extractFrontmatter(noteText); // Create the question list this.questionList = this.doCreateQuestionList( @@ -189,10 +188,9 @@ export class NoteQuestionParser { // within frontmatter appear on separate lines) // private analyseTagCacheList(tagCacheList: TagCache[]): [TopicPathList, TopicPathList[]] { - // The tag (e.g. "#flashcards") must be a valid flashcard tag as per the user settings - this.flashcardTagList = tagCacheList.filter( - (item) => SettingsUtil.isFlashcardTag(this.settings, item.tag) + this.flashcardTagList = tagCacheList.filter((item) => + SettingsUtil.isFlashcardTag(this.settings, item.tag), ); if (this.flashcardTagList.length > 0) { // To simplify analysis, sort the flashcard list ordered by line number @@ -204,20 +202,31 @@ export class NoteQuestionParser { frontmatterLineCount = splitTextIntoLineArray(this.frontmatterText).length; } - const frontmatterTopicPathList: TopicPathList = this.determineFrontmatterTopicPathList(this.flashcardTagList, frontmatterLineCount); - const contentTopicPathList: TopicPathList[] = this.determineContentTopicPathList(this.flashcardTagList, frontmatterLineCount); + const frontmatterTopicPathList: TopicPathList = this.determineFrontmatterTopicPathList( + this.flashcardTagList, + frontmatterLineCount, + ); + const contentTopicPathList: TopicPathList[] = this.determineContentTopicPathList( + this.flashcardTagList, + frontmatterLineCount, + ); return [frontmatterTopicPathList, contentTopicPathList]; } - private determineFrontmatterTopicPathList(flashcardTagList: TagCache[], frontmatterLineCount: number): TopicPathList { + private determineFrontmatterTopicPathList( + flashcardTagList: TagCache[], + frontmatterLineCount: number, + ): TopicPathList { let result: TopicPathList = null; // Filter for tags that are: // 1. specified in the user settings as flashcardTags, and // 2. is not question specific (determined by line number) - i.e. is "note level" const noteLevelTagList: TagCache[] = flashcardTagList.filter( - (item) => (item.position.start.line == frontmatterTagPseudoLineNum) && this.isNoteLevelFlashcardTag(item) + (item) => + item.position.start.line == frontmatterTagPseudoLineNum && + this.isNoteLevelFlashcardTag(item), ); if (noteLevelTagList.length > 0) { // Treat the frontmatter slightly differently (all tags grouped together even if on separate lines) @@ -227,19 +236,27 @@ export class NoteQuestionParser { ); if (frontmatterTagCacheList.length > 0) - result = this.createTopicPathList(frontmatterTagCacheList, frontmatterTagPseudoLineNum); + result = this.createTopicPathList( + frontmatterTagCacheList, + frontmatterTagPseudoLineNum, + ); } } return result; } - private determineContentTopicPathList(flashcardTagList: TagCache[], frontmatterLineCount: number): TopicPathList[] { + private determineContentTopicPathList( + flashcardTagList: TagCache[], + frontmatterLineCount: number, + ): TopicPathList[] { const result: TopicPathList[] = [] as TopicPathList[]; // NOTE: Line numbers are zero based, therefore don't add 1 to frontmatterLineCount to get contentStartLineNum const contentStartLineNum: number = frontmatterLineCount; const contentTagCacheList: TagCache[] = flashcardTagList.filter( - (item) => (item.position.start.line >= contentStartLineNum) && this.isNoteLevelFlashcardTag(item), + (item) => + item.position.start.line >= contentStartLineNum && + this.isNoteLevelFlashcardTag(item), ); // We group together all tags that are on the same line, taking advantage of flashcardTagList being ordered by line number @@ -265,8 +282,8 @@ export class NoteQuestionParser { const tagLineNum: number = tagItem.position.start.line; // Check that the tag is not question specific (determined by line number) - const isQuestionSpecific: boolean = this.questionList.some( - (q) => q.parsedQuestionInfo.isQuestionLineNum(tagLineNum) + const isQuestionSpecific: boolean = this.questionList.some((q) => + q.parsedQuestionInfo.isQuestionLineNum(tagLineNum), ); return !isQuestionSpecific; } diff --git a/src/SRFile.ts b/src/SRFile.ts index e67a07e2..fb0bdec7 100644 --- a/src/SRFile.ts +++ b/src/SRFile.ts @@ -55,7 +55,6 @@ export class SrTFile implements ISRFile { const result: TagCache[] = [] as TagCache[]; const frontmatterTags: string = frontmatter != null ? frontmatter["tags"] + "" : null; if (frontmatterTags) { - // Parse the frontmatter tag string into a list, each entry including the leading "#" const tagStrList: string[] = parseObsidianFrontmatterTag(frontmatterTags); for (const str of tagStrList) { diff --git a/src/parser.ts b/src/parser.ts index 5069369f..3cc06542 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -22,9 +22,9 @@ export class ParsedQuestionInfo { /** * Returns flashcards found in `text` - * + * * It is best that the text does not contain frontmatter, see extractFrontmatter for reasoning - * + * * Multi-line question with blank lines user workaround: * As of 3/04/2024 there is no support for including blank lines within multi-line questions * As a workaround, one user uses a zero width Unicode character - U+200B diff --git a/src/util/utils.ts b/src/util/utils.ts index 311fc501..6e4bfef8 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -96,18 +96,18 @@ export function stringTrimStart(str: string): [string, string] { return [ws, trimmed]; } -// +// // 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) -// +// export function extractFrontmatter(str: string): [string, string] { let frontmatter: string = ""; let content: string = ""; diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts index 25403271..ae42d74e 100644 --- a/tests/unit/NoteQuestionParser.test.ts +++ b/tests/unit/NoteQuestionParser.test.ts @@ -551,10 +551,10 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу topicPathList: TopicPathList.fromPsv("#flashcards/English", 3), // #flashcards/English is on the 4th line, line number 3 cards: [ new Card({ - front: "Stop trying [...] for sympathy. // доить толпу", - back: `Stop trying to milk the crowd for sympathy. // доить толпу`, - }) - ] + front: "Stop trying [...] for sympathy. // доить толпу", + back: `Stop trying to milk the crowd for sympathy. // доить толпу`, + }), + ], }, ]; expect( @@ -665,7 +665,7 @@ Zotero Link: zotero://select/items/@Talibanwar20012018 What year was the Taliban Emirate founded?::1996 #flashcards `; let noteFile: ISRFile = new UnitTestSRFile(noteText); - + let folderTopicPath: TopicPath = TopicPath.emptyPath; let expected = [ { @@ -673,17 +673,16 @@ What year was the Taliban Emirate founded?::1996 #flashcards topicPathList: TopicPathList.fromPsv("#flashcards", 12), cards: [ new Card({ - front: "What year was the Taliban Emirate founded?", - back: "1996 #flashcards" - }) - ] + front: "What year was the Taliban Emirate founded?", + back: "1996 #flashcards", + }), + ], }, ]; expect( await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), ).toMatchObject(expected); }); - }); }); @@ -711,10 +710,10 @@ In computer-science, a *heap* is a tree-based data-structure, that satisfies the topicPathList: TopicPathList.fromPsv("#flashcards", frontmatterTagPseudoLineNum), cards: [ new Card({ - front: `**What is a Heap?**`, - back: "In computer-science, a *heap* is a tree-based data-structure, that satisfies the *heap property*. A heap is a complete *binary-tree*!" - }) - ] + front: `**What is a Heap?**`, + back: "In computer-science, a *heap* is a tree-based data-structure, that satisfies the *heap property*. A heap is a complete *binary-tree*!", + }), + ], }, ]; expect( @@ -742,10 +741,10 @@ In computer-science, a *heap* is a tree-based data-structure, that satisfies the topicPathList: TopicPathList.fromPsv("#flashcards", frontmatterTagPseudoLineNum), cards: [ new Card({ - front: "", - back: "In computer-science, a *heap* is a tree-based data-structure, that satisfies the *heap property*. A heap is a complete *binary-tree*!" - }) - ] + front: "", + back: "In computer-science, a *heap* is a tree-based data-structure, that satisfies the *heap property*. A heap is a complete *binary-tree*!", + }), + ], }, ]; expect( @@ -772,20 +771,20 @@ A::B topicPathList: TopicPathList.fromPsv("#flashcards", frontmatterTagPseudoLineNum), cards: [ new Card({ - front: "In computer-science, a *heap* is", - back: "a tree-based data-structure" - }) - ] + front: "In computer-science, a *heap* is", + back: "a tree-based data-structure", + }), + ], }, { questionType: CardType.SingleLineBasic, topicPathList: TopicPathList.fromPsv("#flashcards", frontmatterTagPseudoLineNum), cards: [ new Card({ - front: "A", - back: "B" - }) - ] + front: "A", + back: "B", + }), + ], }, ]; expect(