From 657005ed66b3c5759275a1080dfd7fa2a46c0ae7 Mon Sep 17 00:00:00 2001 From: currently-coding <165482223+currently-coding@users.noreply.github.com> Date: Sat, 28 Jun 2025 06:58:16 +0200 Subject: [PATCH 01/10] added callouts to settings and options with defaults --- src/note-question-parser.ts | 704 ++++++++++++++++++------------------ src/question.ts | 493 ++++++++++++------------- src/settings.ts | 364 ++++++++++--------- 3 files changed, 783 insertions(+), 778 deletions(-) diff --git a/src/note-question-parser.ts b/src/note-question-parser.ts index 491aca80..145c3d34 100644 --- a/src/note-question-parser.ts +++ b/src/note-question-parser.ts @@ -10,359 +10,359 @@ import { CardFrontBack, CardFrontBackUtil } from "src/question-type"; import { SettingsUtil, SRSettings } from "src/settings"; import { TopicPath, TopicPathList } from "src/topic-path"; import { - splitNoteIntoFrontmatterAndContent, - splitTextIntoLineArray, - TextDirection, + splitNoteIntoFrontmatterAndContent, + splitTextIntoLineArray, + TextDirection, } from "src/utils/strings"; export class NoteQuestionParser { - settings: SRSettings; - 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[]; - - // 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) { - this.settings = settings; - } - - async createQuestionList( - noteFile: ISRFile, - defaultTextDirection: TextDirection, - folderTopicPath: TopicPath, - onlyKeepQuestionsWithTopicPath: boolean, - ): Promise { - this.noteFile = noteFile; - // For efficiency, we first get the tag list from the Obsidian cache - // (this only gives the tag names, not the line numbers, but this is sufficient for this first step) - const tagCacheList: string[] = noteFile.getAllTagsFromCache(); - const hasTopicPaths: boolean = - tagCacheList.some((item) => SettingsUtil.isFlashcardTag(this.settings, item)) || - folderTopicPath.hasPath; - - if (hasTopicPaths) { - // Reading the file is relatively an expensive operation, so we only do this when needed - const noteText: string = await noteFile.read(); - - // Now that we know there are relevant flashcard tags in the file, we can get the more detailed info - // that includes the line numbers of each tag - const tagCompleteList: TagCache[] = noteFile.getAllTagsFromText(); - - // The following analysis can require fair computation. - // There is no point doing it if there aren't any topic paths - [this.frontmatterText, this.contentText] = splitNoteIntoFrontmatterAndContent(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, - ); - - // For each question, determine it's TopicPathList - [this.frontmatterTopicPathList, this.contentTopicPathInfo] = - this.analyseTagCacheList(tagCompleteList); - for (const question of this.questionList) { - question.topicPathList = this.determineQuestionTopicPathList(question); - } - - // Now only keep questions that have a topic list - if (onlyKeepQuestionsWithTopicPath) { - this.questionList = this.questionList.filter((q) => q.topicPathList); - } - } else { - this.questionList = [] as Question[]; - } - return this.questionList; - } - - private doCreateQuestionList( - noteText: string, - textDirection: TextDirection, - folderTopicPath: TopicPath, - tagCacheList: TagCache[], - ): Question[] { - this.noteText = noteText; - this.noteLines = splitTextIntoLineArray(noteText); - this.folderTopicPath = folderTopicPath; - this.tagCacheList = tagCacheList; - - const result: Question[] = []; - const parsedQuestionInfoList: ParsedQuestionInfo[] = this.parseQuestions(); - for (const parsedQuestionInfo of parsedQuestionInfoList) { - 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( - question.questionType, - question.questionText.actualQuestion, - this.settings, - ); - - // And if the card has been reviewed, then scheduling info as well - let cardScheduleInfoList: RepItemScheduleInfo[] = - DataStore.getInstance().questionCreateSchedule( - question.questionText.original, - null, - ); - - // we have some extra scheduling dates to delete - const correctLength = cardFrontBackList.length; - if (cardScheduleInfoList.length > correctLength) { - question.hasChanged = true; - cardScheduleInfoList = cardScheduleInfoList.slice(0, correctLength); - } - - // Create the list of card objects, and attach to the question - const cardList: Card[] = this.createCardList(cardFrontBackList, cardScheduleInfoList); - question.setCardList(cardList); - result.push(question); - } - return result; - } - - private parseQuestions(): ParsedQuestionInfo[] { - const settings = this.settings; - const parserOptions: ParserOptions = { - singleLineCardSeparator: settings.singleLineCardSeparator, - singleLineReversedCardSeparator: settings.singleLineReversedCardSeparator, - multilineCardSeparator: settings.multilineCardSeparator, - multilineReversedCardSeparator: settings.multilineReversedCardSeparator, - multilineCardEndMarker: settings.multilineCardEndMarker, - clozePatterns: settings.clozePatterns, - }; - - // We pass contentText which has the frontmatter blanked out; see extractFrontmatter for reasoning - return parse(this.contentText, parserOptions); - } - - private createQuestionObject( - parsedQuestionInfo: ParsedQuestionInfo, - textDirection: TextDirection, - ): Question { - const questionContext: string[] = this.noteFile.getQuestionContext( - parsedQuestionInfo.firstLineNum, - ); - const result = Question.Create( - this.settings, - parsedQuestionInfo, - null, // We haven't worked out the TopicPathList yet - textDirection, - questionContext, - ); - return result; - } - - private createCardList( - cardFrontBackList: CardFrontBack[], - cardScheduleInfoList: RepItemScheduleInfo[], - ): Card[] { - const siblings: Card[] = []; - - // One card for each CardFrontBack, regardless if there is scheduled info for it - for (let i = 0; i < cardFrontBackList.length; i++) { - const { front, back } = cardFrontBackList[i]; - - const hasScheduleInfo: boolean = i < cardScheduleInfoList.length; - const schedule: RepItemScheduleInfo = cardScheduleInfoList[i]; - - const cardObj: Card = new Card({ - front, - back, - cardIdx: i, - }); - - cardObj.scheduleInfo = hasScheduleInfo ? schedule : null; - - siblings.push(cardObj); - } - return siblings; - } - - // - // Given the complete list of tags within a note: - // 1. Only keep tags that are specified in the user settings as flashcardTags - // 2. Filter out tags that are question specific - // (these will be parsed separately by class QuestionText) - // 3. Combine all tags present logically grouped together into a single entry - // - All tags present on the same line grouped together - // - All tags within frontmatter grouped together (note that multiple tags - // 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), - ); - 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[] = flashcardTagList.filter( - (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) - if (this.frontmatterText) { - const frontmatterTagCacheList = noteLevelTagList.filter( - (item) => item.position.start.line < frontmatterLineCount, - ); - - if (frontmatterTagCacheList.length > 0) - result = this.createTopicPathList( - frontmatterTagCacheList, - frontmatterTagPseudoLineNum, - ); - } - } - 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 tag of contentTagCacheList) { - if (list.length != 0) { - const startLineNum: number = list[0].position.start.line; - if (startLineNum != tag.position.start.line) { - result.push(this.createTopicPathList(list, startLineNum)); - list = [] as TagCache[]; - } - } - list.push(tag); - } - if (list.length > 0) { - const startLineNum: number = list[0].position.start.line; - result.push(this.createTopicPathList(list, startLineNum)); - } - return result; - } - - private isNoteLevelFlashcardTag(tagItem: TagCache): boolean { - 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), - ); - return !isQuestionSpecific; - } - - private createTopicPathList(tagCacheList: TagCache[], lineNum: number): TopicPathList { - const list: TopicPath[] = [] as TopicPath[]; - for (const tagCache of tagCacheList) { - list.push(TopicPath.getTopicPathFromTag(tagCache.tag)); - } - return new TopicPathList(list, lineNum); - } - - private createTopicPathListFromSingleTag(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). - // - // If the question has an associated question specific TopicPath, then that is returned. - // - // Else the first TopicPathList prior to the question (in the order present in the file) is returned. - // That could be either the tags within the note's frontmatter, or tags on lines within the note's content. - private determineQuestionTopicPathList(question: Question): TopicPathList { - let result: TopicPathList; - if (this.settings.convertFoldersToDecks) { - result = new TopicPathList([this.folderTopicPath]); - } else { - // If present, the question specific TopicPath takes precedence over everything else - const questionText: QuestionText = question.questionText; - if (questionText.topicPathWithWs) - result = new TopicPathList( - [questionText.topicPathWithWs.topicPath], - question.parsedQuestionInfo.firstLineNum, - ); - else { - // By default we start off with any TopicPathList present in the frontmatter - result = this.frontmatterTopicPathList; - - // 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 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 - // This could occur if the only topic tags present are question specific - if (!result && this.flashcardTagList.length > 0) { - result = this.createTopicPathListFromSingleTag(this.flashcardTagList[0]); - } - } - } - return result; - } + settings: SRSettings; + 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[]; + + // 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) { + this.settings = settings; + } + + async createQuestionList( + noteFile: ISRFile, + defaultTextDirection: TextDirection, + folderTopicPath: TopicPath, + onlyKeepQuestionsWithTopicPath: boolean, + ): Promise { + this.noteFile = noteFile; + // For efficiency, we first get the tag list from the Obsidian cache + // (this only gives the tag names, not the line numbers, but this is sufficient for this first step) + const tagCacheList: string[] = noteFile.getAllTagsFromCache(); + const hasTopicPaths: boolean = + tagCacheList.some((item) => SettingsUtil.isFlashcardTag(this.settings, item)) || + folderTopicPath.hasPath; + + if (hasTopicPaths) { + // Reading the file is relatively an expensive operation, so we only do this when needed + const noteText: string = await noteFile.read(); + + // Now that we know there are relevant flashcard tags in the file, we can get the more detailed info + // that includes the line numbers of each tag + const tagCompleteList: TagCache[] = noteFile.getAllTagsFromText(); + + // The following analysis can require fair computation. + // There is no point doing it if there aren't any topic paths + [this.frontmatterText, this.contentText] = splitNoteIntoFrontmatterAndContent(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, + ); + + // For each question, determine it's TopicPathList + [this.frontmatterTopicPathList, this.contentTopicPathInfo] = + this.analyseTagCacheList(tagCompleteList); + for (const question of this.questionList) { + question.topicPathList = this.determineQuestionTopicPathList(question); + } + + // Now only keep questions that have a topic list + if (onlyKeepQuestionsWithTopicPath) { + this.questionList = this.questionList.filter((q) => q.topicPathList); + } + } else { + this.questionList = [] as Question[]; + } + return this.questionList; + } + + private doCreateQuestionList( + noteText: string, + textDirection: TextDirection, + folderTopicPath: TopicPath, + tagCacheList: TagCache[], + ): Question[] { + this.noteText = noteText; + this.noteLines = splitTextIntoLineArray(noteText); + this.folderTopicPath = folderTopicPath; + this.tagCacheList = tagCacheList; + + const result: Question[] = []; + const parsedQuestionInfoList: ParsedQuestionInfo[] = this.parseQuestions(); + for (const parsedQuestionInfo of parsedQuestionInfoList) { + 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( + question.questionType, + question.questionText.actualQuestion, + this.settings, + ); + + // And if the card has been reviewed, then scheduling info as well + let cardScheduleInfoList: RepItemScheduleInfo[] = + DataStore.getInstance().questionCreateSchedule( + question.questionText.original, + null, + ); + + // we have some extra scheduling dates to delete + const correctLength = cardFrontBackList.length; + if (cardScheduleInfoList.length > correctLength) { + question.hasChanged = true; + cardScheduleInfoList = cardScheduleInfoList.slice(0, correctLength); + } + + // Create the list of card objects, and attach to the question + const cardList: Card[] = this.createCardList(cardFrontBackList, cardScheduleInfoList); + question.setCardList(cardList); + result.push(question); + } + return result; + } + + private parseQuestions(): ParsedQuestionInfo[] { + const settings = this.settings; + const parserOptions: ParserOptions = { + singleLineCardSeparator: settings.singleLineCardSeparator, + singleLineReversedCardSeparator: settings.singleLineReversedCardSeparator, + multilineCardSeparator: settings.multilineCardSeparator, + multilineReversedCardSeparator: settings.multilineReversedCardSeparator, + multilineCardEndMarker: settings.multilineCardEndMarker, + clozePatterns: settings.clozePatterns, + }; + + // We pass contentText which has the frontmatter blanked out; see extractFrontmatter for reasoning + return parse(this.contentText, parserOptions); + } + + private createQuestionObject( + parsedQuestionInfo: ParsedQuestionInfo, + textDirection: TextDirection, + ): Question { + const questionContext: string[] = this.noteFile.getQuestionContext( + parsedQuestionInfo.firstLineNum, + ); + const result = Question.Create( + this.settings, + parsedQuestionInfo, + null, // We haven't worked out the TopicPathList yet + textDirection, + questionContext, + ); + return result; + } + + private createCardList( + cardFrontBackList: CardFrontBack[], + cardScheduleInfoList: RepItemScheduleInfo[], + ): Card[] { + const siblings: Card[] = []; + + // One card for each CardFrontBack, regardless if there is scheduled info for it + for (let i = 0; i < cardFrontBackList.length; i++) { + const { front, back } = cardFrontBackList[i]; + + const hasScheduleInfo: boolean = i < cardScheduleInfoList.length; + const schedule: RepItemScheduleInfo = cardScheduleInfoList[i]; + + const cardObj: Card = new Card({ + front, + back, + cardIdx: i, + }); + + cardObj.scheduleInfo = hasScheduleInfo ? schedule : null; + + siblings.push(cardObj); + } + return siblings; + } + + // + // Given the complete list of tags within a note: + // 1. Only keep tags that are specified in the user settings as flashcardTags + // 2. Filter out tags that are question specific + // (these will be parsed separately by class QuestionText) + // 3. Combine all tags present logically grouped together into a single entry + // - All tags present on the same line grouped together + // - All tags within frontmatter grouped together (note that multiple tags + // 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), + ); + 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[] = flashcardTagList.filter( + (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) + if (this.frontmatterText) { + const frontmatterTagCacheList = noteLevelTagList.filter( + (item) => item.position.start.line < frontmatterLineCount, + ); + + if (frontmatterTagCacheList.length > 0) + result = this.createTopicPathList( + frontmatterTagCacheList, + frontmatterTagPseudoLineNum, + ); + } + } + 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 tag of contentTagCacheList) { + if (list.length != 0) { + const startLineNum: number = list[0].position.start.line; + if (startLineNum != tag.position.start.line) { + result.push(this.createTopicPathList(list, startLineNum)); + list = [] as TagCache[]; + } + } + list.push(tag); + } + if (list.length > 0) { + const startLineNum: number = list[0].position.start.line; + result.push(this.createTopicPathList(list, startLineNum)); + } + return result; + } + + private isNoteLevelFlashcardTag(tagItem: TagCache): boolean { + 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), + ); + return !isQuestionSpecific; + } + + private createTopicPathList(tagCacheList: TagCache[], lineNum: number): TopicPathList { + const list: TopicPath[] = [] as TopicPath[]; + for (const tagCache of tagCacheList) { + list.push(TopicPath.getTopicPathFromTag(tagCache.tag)); + } + return new TopicPathList(list, lineNum); + } + + private createTopicPathListFromSingleTag(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). + // + // If the question has an associated question specific TopicPath, then that is returned. + // + // Else the first TopicPathList prior to the question (in the order present in the file) is returned. + // That could be either the tags within the note's frontmatter, or tags on lines within the note's content. + private determineQuestionTopicPathList(question: Question): TopicPathList { + let result: TopicPathList; + if (this.settings.convertFoldersToDecks) { + result = new TopicPathList([this.folderTopicPath]); + } else { + // If present, the question specific TopicPath takes precedence over everything else + const questionText: QuestionText = question.questionText; + if (questionText.topicPathWithWs) + result = new TopicPathList( + [questionText.topicPathWithWs.topicPath], + question.parsedQuestionInfo.firstLineNum, + ); + else { + // By default we start off with any TopicPathList present in the frontmatter + result = this.frontmatterTopicPathList; + + // 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 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 + // This could occur if the only topic tags present are question specific + if (!result && this.flashcardTagList.length > 0) { + result = this.createTopicPathListFromSingleTag(this.flashcardTagList[0]); + } + } + } + return result; + } } diff --git a/src/question.ts b/src/question.ts index 9ce63592..a9a533d6 100644 --- a/src/question.ts +++ b/src/question.ts @@ -1,7 +1,7 @@ import { Card } from "src/card"; import { - OBSIDIAN_BLOCK_ID_ENDOFLINE_REGEX, - OBSIDIAN_TAG_AT_STARTOFLINE_REGEX, + OBSIDIAN_BLOCK_ID_ENDOFLINE_REGEX, + OBSIDIAN_TAG_AT_STARTOFLINE_REGEX, } from "src/constants"; import { DataStoreAlgorithm } from "src/data-store-algorithm/data-store-algorithm"; import { DataStore } from "src/data-stores/base/data-store"; @@ -12,11 +12,12 @@ import { TopicPath, TopicPathList, TopicPathWithWs } from "src/topic-path"; import { cyrb53, MultiLineTextFinder, stringTrimStart, TextDirection } from "src/utils/strings"; export enum CardType { - SingleLineBasic, - SingleLineReversed, - MultiLineBasic, - MultiLineReversed, - Cloze, + SingleLineBasic, + SingleLineReversed, + MultiLineBasic, + MultiLineReversed, + Cloze, + Callout, } // QuestionText comprises the following components: @@ -73,246 +74,246 @@ export enum CardType { // Question text with block identifier: // Q2::A2 ^d7cee0 export class QuestionText { - // Complete text including all components, as read from file - original: string; - - // The question topic path (only present if topic path included in original text) - // If present, it also includes whitespace before and after the topic path itself - topicPathWithWs: TopicPathWithWs; - - // 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 - // 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, - 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 - this.textHash = cyrb53(this.formatTopicAndQuestion()); - } - - endsWithCodeBlock(): boolean { - return this.actualQuestion.endsWith("```"); - } - - static create( - original: string, - textDirection: TextDirection, - settings: SRSettings, - ): QuestionText { - const [topicPathWithWs, actualQuestion, blockId] = this.splitText(original, settings); - - return new QuestionText(original, topicPathWithWs, actualQuestion, textDirection, blockId); - } - - static splitText(original: string, settings: SRSettings): [TopicPathWithWs, string, string] { - const originalWithoutSR = DataStore.getInstance().questionRemoveScheduleInfo(original); - let actualQuestion: string = originalWithoutSR.trimEnd(); - - let topicPathWithWs: TopicPathWithWs = null; - 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); - } - } - - // actualQuestion - Question [whitespace blockId] - [actualQuestion, blockId] = this.extractObsidianBlockId(actualQuestion); - - return [topicPathWithWs, actualQuestion, blockId]; - } - - 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(); - } - - result += this.actualQuestion; - return result; - } + // Complete text including all components, as read from file + original: string; + + // The question topic path (only present if topic path included in original text) + // If present, it also includes whitespace before and after the topic path itself + topicPathWithWs: TopicPathWithWs; + + // 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 + // 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, + 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 + this.textHash = cyrb53(this.formatTopicAndQuestion()); + } + + endsWithCodeBlock(): boolean { + return this.actualQuestion.endsWith("```"); + } + + static create( + original: string, + textDirection: TextDirection, + settings: SRSettings, + ): QuestionText { + const [topicPathWithWs, actualQuestion, blockId] = this.splitText(original, settings); + + return new QuestionText(original, topicPathWithWs, actualQuestion, textDirection, blockId); + } + + static splitText(original: string, settings: SRSettings): [TopicPathWithWs, string, string] { + const originalWithoutSR = DataStore.getInstance().questionRemoveScheduleInfo(original); + let actualQuestion: string = originalWithoutSR.trimEnd(); + + let topicPathWithWs: TopicPathWithWs = null; + 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); + } + } + + // actualQuestion - Question [whitespace blockId] + [actualQuestion, blockId] = this.extractObsidianBlockId(actualQuestion); + + return [topicPathWithWs, actualQuestion, blockId]; + } + + 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(); + } + + result += this.actualQuestion; + return result; + } } export class Question { - note: Note; - parsedQuestionInfo: ParsedQuestionInfo; - topicPathList: TopicPathList; - questionText: QuestionText; - hasEditLaterTag: boolean; - questionContext: string[]; - cards: Card[]; - hasChanged: boolean; - - get questionType(): CardType { - return this.parsedQuestionInfo.cardType; - } - get lineNo(): number { - return this.parsedQuestionInfo.firstLineNum; - } - - constructor(init?: Partial) { - Object.assign(this, init); - } - - getHtmlCommentSeparator(settings: SRSettings): string { - 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)); - } - - formatForNote(settings: SRSettings): string { - 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 = - DataStoreAlgorithm.getInstance().questionFormatScheduleAsHtmlComment(this); - 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; - } - - updateQuestionWithinNoteText(noteText: string, settings: SRSettings): string { - const originalText: string = this.questionText.original; - - // Get the entire text for the question including: - // 1. the topic path (if present), - // 2. the question text - // 3. the schedule HTML comment (if present) - const replacementText = this.formatForNote(settings); - - let newText = MultiLineTextFinder.findAndReplace(noteText, originalText, replacementText); - if (newText) { - // 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( - 0, - 100, - )} in note: ${noteText.substring(0, 100)}`, - ); - newText = noteText; - } - return newText; - } - - async writeQuestion(settings: SRSettings): Promise { - const fileText: string = await this.note.file.read(); - - const newText: string = this.updateQuestionWithinNoteText(fileText, settings); - await this.note.file.write(newText); - this.hasChanged = false; - } - - formatTopicPathList(): string { - return this.topicPathList.format("|"); - } - - static Create( - 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, - textDirection, - settings, - ); - - let topicPathList: TopicPathList = noteTopicPathList; - if (questionText.topicPathWithWs) { - topicPathList = new TopicPathList([questionText.topicPathWithWs.topicPath]); - } - - const result: Question = new Question({ - parsedQuestionInfo, - topicPathList, - questionText, - hasEditLaterTag, - questionContext: context, - cards: null, - hasChanged: false, - }); - - return result; - } + note: Note; + parsedQuestionInfo: ParsedQuestionInfo; + topicPathList: TopicPathList; + questionText: QuestionText; + hasEditLaterTag: boolean; + questionContext: string[]; + cards: Card[]; + hasChanged: boolean; + + get questionType(): CardType { + return this.parsedQuestionInfo.cardType; + } + get lineNo(): number { + return this.parsedQuestionInfo.firstLineNum; + } + + constructor(init?: Partial) { + Object.assign(this, init); + } + + getHtmlCommentSeparator(settings: SRSettings): string { + 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)); + } + + formatForNote(settings: SRSettings): string { + 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 = + DataStoreAlgorithm.getInstance().questionFormatScheduleAsHtmlComment(this); + 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; + } + + updateQuestionWithinNoteText(noteText: string, settings: SRSettings): string { + const originalText: string = this.questionText.original; + + // Get the entire text for the question including: + // 1. the topic path (if present), + // 2. the question text + // 3. the schedule HTML comment (if present) + const replacementText = this.formatForNote(settings); + + let newText = MultiLineTextFinder.findAndReplace(noteText, originalText, replacementText); + if (newText) { + // 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( + 0, + 100, + )} in note: ${noteText.substring(0, 100)}`, + ); + newText = noteText; + } + return newText; + } + + async writeQuestion(settings: SRSettings): Promise { + const fileText: string = await this.note.file.read(); + + const newText: string = this.updateQuestionWithinNoteText(fileText, settings); + await this.note.file.write(newText); + this.hasChanged = false; + } + + formatTopicPathList(): string { + return this.topicPathList.format("|"); + } + + static Create( + 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, + textDirection, + settings, + ); + + let topicPathList: TopicPathList = noteTopicPathList; + if (questionText.topicPathWithWs) { + topicPathList = new TopicPathList([questionText.topicPathWithWs.topicPath]); + } + + const result: Question = new Question({ + parsedQuestionInfo, + topicPathList, + questionText, + hasEditLaterTag, + questionContext: context, + cards: null, + hasChanged: false, + }); + + return result; + } } diff --git a/src/settings.ts b/src/settings.ts index d2c8a3da..f993d7e0 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -6,193 +6,197 @@ import { t } from "src/lang/helpers"; import { pathMatchesPattern } from "src/utils/fs"; export interface SRSettings { - // flashcards - flashcardTags: string[]; - convertFoldersToDecks: boolean; - burySiblingCards: boolean; - randomizeCardOrder: boolean; - flashcardCardOrder: string; - flashcardDeckOrder: string; - convertHighlightsToClozes: boolean; - convertBoldTextToClozes: boolean; - convertCurlyBracketsToClozes: boolean; - clozePatterns: string[]; - singleLineCardSeparator: string; - singleLineReversedCardSeparator: string; - multilineCardSeparator: string; - multilineReversedCardSeparator: string; - multilineCardEndMarker: string; - editLaterTag: string; - - // notes - enableNoteReviewPaneOnStartup: boolean; - tagsToReview: string[]; - noteFoldersToIgnore: string[]; - openRandomNote: boolean; - autoNextNote: boolean; - disableFileMenuReviewOptions: boolean; - maxNDaysNotesReviewQueue: number; - - // UI preferences - showRibbonIcon: boolean; - showStatusBar: boolean; - initiallyExpandAllSubdecksInTree: boolean; - showContextInCards: boolean; - showIntervalInReviewButtons: boolean; - flashcardHeightPercentage: number; - flashcardWidthPercentage: number; - flashcardEasyText: string; - flashcardGoodText: string; - flashcardHardText: string; - reviewButtonDelay: number; - openViewInNewTab: boolean; - - // algorithm - algorithm: string; - baseEase: number; - lapsesIntervalChange: number; - easyBonus: number; - loadBalance: boolean; - maximumInterval: number; - maxLinkFactor: number; - - // storage - dataStore: string; - cardCommentOnSameLine: boolean; - - // logging - showSchedulingDebugMessages: boolean; - showParserDebugMessages: boolean; + // flashcards + flashcardTags: string[]; + convertFoldersToDecks: boolean; + burySiblingCards: boolean; + randomizeCardOrder: boolean; + flashcardCardOrder: string; + flashcardDeckOrder: string; + convertHighlightsToClozes: boolean; + convertBoldTextToClozes: boolean; + convertCurlyBracketsToClozes: boolean; + clozePatterns: string[]; + singleLineCardSeparator: string; + singleLineReversedCardSeparator: string; + multilineCardSeparator: string; + multilineReversedCardSeparator: string; + multilineCardEndMarker: string; + calloutCardMarker: string, + calloutLineMarker: string + editLaterTag: string; + + // notes + enableNoteReviewPaneOnStartup: boolean; + tagsToReview: string[]; + noteFoldersToIgnore: string[]; + openRandomNote: boolean; + autoNextNote: boolean; + disableFileMenuReviewOptions: boolean; + maxNDaysNotesReviewQueue: number; + + // UI preferences + showRibbonIcon: boolean; + showStatusBar: boolean; + initiallyExpandAllSubdecksInTree: boolean; + showContextInCards: boolean; + showIntervalInReviewButtons: boolean; + flashcardHeightPercentage: number; + flashcardWidthPercentage: number; + flashcardEasyText: string; + flashcardGoodText: string; + flashcardHardText: string; + reviewButtonDelay: number; + openViewInNewTab: boolean; + + // algorithm + algorithm: string; + baseEase: number; + lapsesIntervalChange: number; + easyBonus: number; + loadBalance: boolean; + maximumInterval: number; + maxLinkFactor: number; + + // storage + dataStore: string; + cardCommentOnSameLine: boolean; + + // logging + showSchedulingDebugMessages: boolean; + showParserDebugMessages: boolean; } export const DEFAULT_SETTINGS: SRSettings = { - // flashcards - flashcardTags: ["#flashcards"], - convertFoldersToDecks: false, - burySiblingCards: false, - randomizeCardOrder: null, - flashcardCardOrder: "DueFirstRandom", - flashcardDeckOrder: "PrevDeckComplete_Sequential", - convertHighlightsToClozes: true, - convertBoldTextToClozes: false, - convertCurlyBracketsToClozes: false, - clozePatterns: ["==[123;;]answer[;;hint]=="], - singleLineCardSeparator: "::", - singleLineReversedCardSeparator: ":::", - multilineCardSeparator: "?", - multilineReversedCardSeparator: "??", - multilineCardEndMarker: "", - editLaterTag: "#edit-later", - - // notes - enableNoteReviewPaneOnStartup: true, - tagsToReview: ["#review"], - noteFoldersToIgnore: ["**/*.excalidraw.md"], - openRandomNote: false, - autoNextNote: false, - disableFileMenuReviewOptions: false, - maxNDaysNotesReviewQueue: 365, - - // UI settings - showRibbonIcon: true, - showStatusBar: true, - initiallyExpandAllSubdecksInTree: false, - showContextInCards: true, - showIntervalInReviewButtons: true, - flashcardHeightPercentage: Platform.isMobile ? 100 : 80, - flashcardWidthPercentage: Platform.isMobile ? 100 : 40, - flashcardEasyText: t("EASY"), - flashcardGoodText: t("GOOD"), - flashcardHardText: t("HARD"), - reviewButtonDelay: 0, - openViewInNewTab: false, - - // algorithm - algorithm: Algorithm.SM_2_OSR, - baseEase: 250, - lapsesIntervalChange: 0.5, - easyBonus: 1.3, - loadBalance: true, - maximumInterval: 36525, - maxLinkFactor: 1.0, - - // storage - dataStore: DataStoreName.NOTES, - cardCommentOnSameLine: false, - - // logging - showSchedulingDebugMessages: false, - showParserDebugMessages: false, + // flashcards + flashcardTags: ["#flashcards"], + convertFoldersToDecks: false, + burySiblingCards: false, + randomizeCardOrder: null, + flashcardCardOrder: "DueFirstRandom", + flashcardDeckOrder: "PrevDeckComplete_Sequential", + convertHighlightsToClozes: true, + convertBoldTextToClozes: false, + convertCurlyBracketsToClozes: false, + clozePatterns: ["==[123;;]answer[;;hint]=="], + singleLineCardSeparator: "::", + singleLineReversedCardSeparator: ":::", + multilineCardSeparator: "?", + multilineReversedCardSeparator: "??", + multilineCardEndMarker: "", + calloutCardMarker: ">[!Question]", + calloutLineMarker: ">", + editLaterTag: "#edit-later", + + // notes + enableNoteReviewPaneOnStartup: true, + tagsToReview: ["#review"], + noteFoldersToIgnore: ["**/*.excalidraw.md"], + openRandomNote: false, + autoNextNote: false, + disableFileMenuReviewOptions: false, + maxNDaysNotesReviewQueue: 365, + + // UI settings + showRibbonIcon: true, + showStatusBar: true, + initiallyExpandAllSubdecksInTree: false, + showContextInCards: true, + showIntervalInReviewButtons: true, + flashcardHeightPercentage: Platform.isMobile ? 100 : 80, + flashcardWidthPercentage: Platform.isMobile ? 100 : 40, + flashcardEasyText: t("EASY"), + flashcardGoodText: t("GOOD"), + flashcardHardText: t("HARD"), + reviewButtonDelay: 0, + openViewInNewTab: false, + + // algorithm + algorithm: Algorithm.SM_2_OSR, + baseEase: 250, + lapsesIntervalChange: 0.5, + easyBonus: 1.3, + loadBalance: true, + maximumInterval: 36525, + maxLinkFactor: 1.0, + + // storage + dataStore: DataStoreName.NOTES, + cardCommentOnSameLine: false, + + // logging + showSchedulingDebugMessages: false, + showParserDebugMessages: false, }; export function upgradeSettings(settings: SRSettings) { - if ( - settings.randomizeCardOrder != null && - settings.flashcardCardOrder == null && - settings.flashcardDeckOrder == null - ) { - settings.flashcardCardOrder = settings.randomizeCardOrder - ? "DueFirstRandom" - : "DueFirstSequential"; - settings.flashcardDeckOrder = "PrevDeckComplete_Sequential"; - - // After the upgrade, we don't need the old attribute any more - settings.randomizeCardOrder = null; - } - - if (settings.clozePatterns == null) { - settings.clozePatterns = []; - - if (settings.convertHighlightsToClozes) - settings.clozePatterns.push("==[123;;]answer[;;hint]=="); - - if (settings.convertBoldTextToClozes) - settings.clozePatterns.push("**[123;;]answer[;;hint]**"); - - if (settings.convertCurlyBracketsToClozes) - settings.clozePatterns.push("{{[123;;]answer[;;hint]}}"); - } + if ( + settings.randomizeCardOrder != null && + settings.flashcardCardOrder == null && + settings.flashcardDeckOrder == null + ) { + settings.flashcardCardOrder = settings.randomizeCardOrder + ? "DueFirstRandom" + : "DueFirstSequential"; + settings.flashcardDeckOrder = "PrevDeckComplete_Sequential"; + + // After the upgrade, we don't need the old attribute any more + settings.randomizeCardOrder = null; + } + + if (settings.clozePatterns == null) { + settings.clozePatterns = []; + + if (settings.convertHighlightsToClozes) + settings.clozePatterns.push("==[123;;]answer[;;hint]=="); + + if (settings.convertBoldTextToClozes) + settings.clozePatterns.push("**[123;;]answer[;;hint]**"); + + if (settings.convertCurlyBracketsToClozes) + settings.clozePatterns.push("{{[123;;]answer[;;hint]}}"); + } } export class SettingsUtil { - static isFlashcardTag(settings: SRSettings, tag: string): boolean { - return SettingsUtil.isTagInList(settings.flashcardTags, tag); - } - - static isPathInNoteIgnoreFolder(settings: SRSettings, path: string): boolean { - return settings.noteFoldersToIgnore.some((folder) => pathMatchesPattern(path, folder)); - } - - static isAnyTagANoteReviewTag(settings: SRSettings, tags: string[]): boolean { - for (const tag of tags) { - if ( - settings.tagsToReview.some( - (tagToReview) => tag === tagToReview || tag.startsWith(tagToReview + "/"), - ) - ) { - return true; - } - } - return false; - } - - // Given a list of tags, return the subset that is in settings.tagsToReview - static filterForNoteReviewTag(settings: SRSettings, tags: string[]): string[] { - const result: string[] = []; - for (const tagToReview of settings.tagsToReview) { - if (tags.some((tag) => tag === tagToReview || tag.startsWith(tagToReview + "/"))) { - result.push(tagToReview); - } - } - return result; - } - - private static isTagInList(tagList: string[], tag: string): boolean { - for (const tagFromList of tagList) { - if (tag === tagFromList || tag.startsWith(tagFromList + "/")) { - return true; - } - } - return false; - } + static isFlashcardTag(settings: SRSettings, tag: string): boolean { + return SettingsUtil.isTagInList(settings.flashcardTags, tag); + } + + static isPathInNoteIgnoreFolder(settings: SRSettings, path: string): boolean { + return settings.noteFoldersToIgnore.some((folder) => pathMatchesPattern(path, folder)); + } + + static isAnyTagANoteReviewTag(settings: SRSettings, tags: string[]): boolean { + for (const tag of tags) { + if ( + settings.tagsToReview.some( + (tagToReview) => tag === tagToReview || tag.startsWith(tagToReview + "/"), + ) + ) { + return true; + } + } + return false; + } + + // Given a list of tags, return the subset that is in settings.tagsToReview + static filterForNoteReviewTag(settings: SRSettings, tags: string[]): string[] { + const result: string[] = []; + for (const tagToReview of settings.tagsToReview) { + if (tags.some((tag) => tag === tagToReview || tag.startsWith(tagToReview + "/"))) { + result.push(tagToReview); + } + } + return result; + } + + private static isTagInList(tagList: string[], tag: string): boolean { + for (const tagFromList of tagList) { + if (tag === tagFromList || tag.startsWith(tagFromList + "/")) { + return true; + } + } + return false; + } } From 48c33e8e5b1409386e471bc7d4ee4c124423f97a Mon Sep 17 00:00:00 2001 From: currently-coding <165482223+currently-coding@users.noreply.github.com> Date: Sat, 28 Jun 2025 07:04:37 +0200 Subject: [PATCH 02/10] added callout to parse function --- src/note-question-parser.ts | 2 + src/parser.ts | 350 ++++++++++++++++++------------------ src/question-type.ts | 224 +++++++++++------------ 3 files changed, 291 insertions(+), 285 deletions(-) diff --git a/src/note-question-parser.ts b/src/note-question-parser.ts index 145c3d34..b7fdf9ad 100644 --- a/src/note-question-parser.ts +++ b/src/note-question-parser.ts @@ -151,6 +151,8 @@ export class NoteQuestionParser { multilineReversedCardSeparator: settings.multilineReversedCardSeparator, multilineCardEndMarker: settings.multilineCardEndMarker, clozePatterns: settings.clozePatterns, + calloutCardMarker: settings.calloutCardMarker, + calloutLineMarker: settings.calloutCardMarker, }; // We pass contentText which has the frontmatter blanked out; see extractFrontmatter for reasoning diff --git a/src/parser.ts b/src/parser.ts index 326a8415..60fb252e 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -5,69 +5,71 @@ import { CardType } from "src/question"; export let debugParser = false; export interface ParserOptions { - singleLineCardSeparator: string; - singleLineReversedCardSeparator: string; - multilineCardSeparator: string; - multilineReversedCardSeparator: string; - multilineCardEndMarker: string; - clozePatterns: string[]; + singleLineCardSeparator: string; + singleLineReversedCardSeparator: string; + multilineCardSeparator: string; + multilineReversedCardSeparator: string; + multilineCardEndMarker: string; + clozePatterns: string[]; + calloutCardMarker: string, + calloutLineMarker: string, } export function setDebugParser(value: boolean) { - debugParser = value; + debugParser = value; } export class ParsedQuestionInfo { - cardType: CardType; - text: string; - - // Line numbers start at 0 - firstLineNum: number; - lastLineNum: number; - - constructor(cardType: CardType, text: string, firstLineNum: number, lastLineNum: number) { - this.cardType = cardType; - this.text = text; - this.firstLineNum = firstLineNum; - this.lastLineNum = lastLineNum; - } - - isQuestionLineNum(lineNum: number): boolean { - return lineNum >= this.firstLineNum && lineNum <= this.lastLineNum; - } + cardType: CardType; + text: string; + + // Line numbers start at 0 + firstLineNum: number; + lastLineNum: number; + + constructor(cardType: CardType, text: string, firstLineNum: number, lastLineNum: number) { + this.cardType = cardType; + this.text = text; + this.firstLineNum = firstLineNum; + this.lastLineNum = lastLineNum; + } + + isQuestionLineNum(lineNum: number): boolean { + return lineNum >= this.firstLineNum && lineNum <= this.lastLineNum; + } } function markerInsideCodeBlock(text: string, marker: string, markerIndex: number): boolean { - let goingBack = markerIndex - 1, - goingForward = markerIndex + marker.length; - let backTicksBefore = 0, - backTicksAfter = 0; - - while (goingBack >= 0) { - if (text[goingBack] === "`") backTicksBefore++; - goingBack--; - } - - while (goingForward < text.length) { - if (text[goingForward] === "`") backTicksAfter++; - goingForward++; - } - - // If there's an odd number of backticks before and after, - // the marker is inside an inline code block - return backTicksBefore % 2 === 1 && backTicksAfter % 2 === 1; + let goingBack = markerIndex - 1, + goingForward = markerIndex + marker.length; + let backTicksBefore = 0, + backTicksAfter = 0; + + while (goingBack >= 0) { + if (text[goingBack] === "`") backTicksBefore++; + goingBack--; + } + + while (goingForward < text.length) { + if (text[goingForward] === "`") backTicksAfter++; + goingForward++; + } + + // If there's an odd number of backticks before and after, + // the marker is inside an inline code block + return backTicksBefore % 2 === 1 && backTicksAfter % 2 === 1; } function hasInlineMarker(text: string, marker: string): boolean { - // No marker provided - if (marker.length == 0) return false; + // No marker provided + if (marker.length == 0) return false; - // Check if the marker is in the text - const markerIdx = text.indexOf(marker); - if (markerIdx === -1) return false; + // Check if the marker is in the text + const markerIdx = text.indexOf(marker); + if (markerIdx === -1) return false; - // Check if it's inside an inline code block - return !markerInsideCodeBlock(text, marker, markerIdx); + // Check if it's inside an inline code block + return !markerInsideCodeBlock(text, marker, markerIdx); } /** @@ -80,128 +82,130 @@ function hasInlineMarker(text: string, marker: string): boolean { * @returns An array of parsed question information */ export function parse(text: string, options: ParserOptions): ParsedQuestionInfo[] { - if (debugParser) { - console.log("Text to parse:\n<<<" + text + ">>>"); - } - - // Sort inline separators by length, longest first - const inlineSeparators = [ - { separator: options.singleLineCardSeparator, type: CardType.SingleLineBasic }, - { separator: options.singleLineReversedCardSeparator, type: CardType.SingleLineReversed }, - ]; - inlineSeparators.sort((a, b) => b.separator.length - a.separator.length); - - const cards: ParsedQuestionInfo[] = []; - let cardText = ""; - let cardType: CardType | null = null; - let firstLineNo = 0, - lastLineNo = 0; - - const clozecrafter = new ClozeCrafter(options.clozePatterns); - const lines: string[] = text.replaceAll("\r\n", "\n").split("\n"); - for (let i = 0; i < lines.length; i++) { - const currentLine = lines[i], - currentTrimmed = lines[i].trim(); - - // Skip everything in HTML comments - if (currentLine.startsWith("")) i++; - i++; - continue; - } - - // Have we reached the end of a card? - const isEmptyLine = currentTrimmed.length == 0; - const hasMultilineCardEndMarker = - options.multilineCardEndMarker && currentTrimmed == options.multilineCardEndMarker; - if ( - // We've probably reached the end of a card - (isEmptyLine && !options.multilineCardEndMarker) || - // Empty line & we're not picking up any card - (isEmptyLine && cardType == null) || - // We've reached the end of a multi line card & - // we're using custom end markers - hasMultilineCardEndMarker - ) { - if (cardType) { - // Create a new card - lastLineNo = i - 1; - cards.push( - new ParsedQuestionInfo(cardType, cardText.trimEnd(), firstLineNo, lastLineNo), - ); - cardType = null; - } - - cardText = ""; - firstLineNo = i + 1; - continue; - } - - // Update card text - if (cardText.length > 0) { - cardText += "\n"; - } - cardText += currentLine.trimEnd(); - - // Pick up inline cards - for (const { separator, type } of inlineSeparators) { - if (hasInlineMarker(currentLine, separator)) { - cardType = type; - break; - } - } - - if (cardType == CardType.SingleLineBasic || cardType == CardType.SingleLineReversed) { - cardText = currentLine; - firstLineNo = i; - - // Pick up scheduling information if present - if (i + 1 < lines.length && lines[i + 1].startsWith("")) i++; + i++; + continue; + } + + // Have we reached the end of a card? + const isEmptyLine = currentTrimmed.length == 0; + const hasMultilineCardEndMarker = + options.multilineCardEndMarker && currentTrimmed == options.multilineCardEndMarker; + if ( + // We've probably reached the end of a card + (isEmptyLine && !options.multilineCardEndMarker) || + // Empty line & we're not picking up any card + (isEmptyLine && cardType == null) || + // We've reached the end of a multi line card & + // we're using custom end markers + hasMultilineCardEndMarker + ) { + if (cardType) { + // Create a new card + lastLineNo = i - 1; + cards.push( + new ParsedQuestionInfo(cardType, cardText.trimEnd(), firstLineNo, lastLineNo), + ); + cardType = null; + } + + cardText = ""; + firstLineNo = i + 1; + continue; + } + + // Update card text + if (cardText.length > 0) { + cardText += "\n"; + } + cardText += currentLine.trimEnd(); + + // Pick up inline cards + for (const { separator, type } of inlineSeparators) { + if (hasInlineMarker(currentLine, separator)) { + cardType = type; + break; + } + } + + if (cardType == CardType.SingleLineBasic || cardType == CardType.SingleLineReversed) { + cardText = currentLine; + firstLineNo = i; + + // Pick up scheduling information if present + if (i + 1 < lines.length && lines[i + 1].startsWith("")) i++; - i++; - continue; - } - - // Have we reached the end of a card? - const isEmptyLine = currentTrimmed.length == 0; - const hasMultilineCardEndMarker = - options.multilineCardEndMarker && currentTrimmed == options.multilineCardEndMarker; - if ( - // We've probably reached the end of a card - (isEmptyLine && !options.multilineCardEndMarker) || - // Empty line & we're not picking up any card - (isEmptyLine && cardType == null) || - // We've reached the end of a multi line card & - // we're using custom end markers - hasMultilineCardEndMarker - ) { - if (cardType) { - // Create a new card - lastLineNo = i - 1; - cards.push( - new ParsedQuestionInfo(cardType, cardText.trimEnd(), firstLineNo, lastLineNo), - ); - cardType = null; - } - - cardText = ""; - firstLineNo = i + 1; - continue; - } - - // Update card text - if (cardText.length > 0) { - cardText += "\n"; - } - cardText += currentLine.trimEnd(); - - // Pick up inline cards - for (const { separator, type } of inlineSeparators) { - if (hasInlineMarker(currentLine, separator)) { - cardType = type; - break; - } - } - - if (cardType == CardType.SingleLineBasic || cardType == CardType.SingleLineReversed) { - cardText = currentLine; - firstLineNo = i; - - // Pick up scheduling information if present - if (i + 1 < lines.length && lines[i + 1].startsWith("")) i++; + i++; + continue; + } + + // Have we reached the end of a card? + const isEmptyLine = currentTrimmed.length == 0; + const hasMultilineCardEndMarker = + options.multilineCardEndMarker && currentTrimmed == options.multilineCardEndMarker; + if ( + // We've probably reached the end of a card + (isEmptyLine && !options.multilineCardEndMarker) || + // Empty line & we're not picking up any card + (isEmptyLine && cardType == null) || + // We've reached the end of a multi line card & + // we're using custom end markers + hasMultilineCardEndMarker + ) { + if (cardType) { + // Create a new card + lastLineNo = i - 1; + cards.push( + new ParsedQuestionInfo(cardType, cardText.trimEnd(), firstLineNo, lastLineNo), + ); + cardType = null; + } + + cardText = ""; + firstLineNo = i + 1; + continue; + } + + // Update card text + if (cardText.length > 0) { + cardText += "\n"; + } + cardText += currentLine.trimEnd(); + + // Pick up inline cards + for (const { separator, type } of inlineSeparators) { + if (hasInlineMarker(currentLine, separator)) { + cardType = type; + break; + } + } + + if (cardType == CardType.SingleLineBasic || cardType == CardType.SingleLineReversed) { + cardText = currentLine; + firstLineNo = i; + + // Pick up scheduling information if present + if (i + 1 < lines.length && lines[i + 1].startsWith("", parserOptions), + ).toEqual([ + [CardType.Callout, ">[!Question] Question\n>Answer ", 0, 1], + ]); + expect( + parseT(">[!Question] Question\n>\n>Answer ", parserOptions), + ).toEqual([ + [CardType.Callout, ">[!Question] Question\n>\n>Answer ", 0, 2], + ]); + expect( + parseT( + ">[!Question] Question\n>Answer 1\n>Answer2 ", + parserOptions, + ), + ).toEqual([ + [ + CardType.Callout, + ">[!Question] Question\n>Answer 1\n>Answer2 ", + 0, + 2, + ], + ]); + expect( + parseT( + ">[!Question] Question\n>Answer 1\n>Answer2\n", + parserOptions, + ), + ).toEqual([ + [ + CardType.Callout, + ">[!Question] Question\n>Answer 1\n>Answer2\n", + 0, + 3, + ], + ]); + expect(parseT(">[!Question] Question\n>Answer line 1\n>Answer line 2", parserOptions)).toEqual([ + [CardType.Callout, ">[!Question] Question\n>Answer line 1\n>Answer line 2", 0, 2], + ]); + expect( + parseT( + "#Title\n\nLine0\n>[!Question] Q1\n>A1\n>AnswerExtra\n\n>[!Question] Q2\n>A2", + parserOptions, + ), + ).toEqual([ + [CardType.Callout, ">[!Question] Q1\n>A1\n>AnswerExtra", 3, 5], + [CardType.Callout, ">[!Question] Q2\n>A2", 7, 8], + ]); + expect( + parseT("#flashcards/tag-on-previous-line\n>[!Question] Question\n>Answer", parserOptions), + ).toEqual([[CardType.Callout, ">[!Question] Question\n>Answer", 1, 2]]); + // custom symbols + expect( + parseT(">[!Custom] Question\n>Answer line 1\n>Answer line 2\n\n", { + singleLineCardSeparator: "::", + singleLineReversedCardSeparator: ":::", + multilineCardSeparator: "?", + multilineReversedCardSeparator: "??", + multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: ">[!Custom]", + clozePatterns: ["**[123;;]answer[;;hint]**"], + }), + ).toEqual([[CardType.Callout, ">[!Custom] Question\n>Answer line 1\n>Answer line 2", 0, 2]]); + expect( + parseT( + ">[!Multi] Question 1\n>Answer line 1\n>Answer line 2\n\n\n>[!Multi] Question 2\n>Answer line 1\n>Answer line 2\n\nirrelavant", + { + singleLineCardSeparator: "::", + singleLineReversedCardSeparator: ":::", + multilineCardSeparator: "?", + multilineReversedCardSeparator: "??", + multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: ">[!Multi]", + clozePatterns: ["**[123;;]answer[;;hint]**"], + }, + ), + ).toEqual([ + [CardType.Callout, ">[!Multi] Question 1\n>Answer line 1\n>Answer line 2", 0, 2], + [CardType.Callout, ">[!Multi] Question 2\n>Answer line 1\n>Answer line 2", 5, 7], + ]); + // empty string or whitespace character provided + expect( + parseT(">[!Question] Question\n>Answer", { + singleLineCardSeparator: "::", + singleLineReversedCardSeparator: ":::", + multilineCardSeparator: "?", + multilineReversedCardSeparator: "??", + multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: "", + clozePatterns: [], + }), + ).toEqual([]); +}); + test("Test parsing of a mix of card types", () => { expect( parseT( From 0e7e2fd9c703a2e04c5f9541319797bae60f7a74 Mon Sep 17 00:00:00 2001 From: currently-coding <165482223+currently-coding@users.noreply.github.com> Date: Sat, 28 Jun 2025 10:05:43 +0200 Subject: [PATCH 07/10] added question-type test --- tests/unit/question-type.test.ts | 192 ++++++++++++++++++------------- 1 file changed, 115 insertions(+), 77 deletions(-) diff --git a/tests/unit/question-type.test.ts b/tests/unit/question-type.test.ts index c8e78786..48d03bb2 100644 --- a/tests/unit/question-type.test.ts +++ b/tests/unit/question-type.test.ts @@ -3,98 +3,136 @@ import { CardFrontBack, CardFrontBackUtil, QuestionTypeClozeFormatter } from "sr import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; test("CardType.SingleLineBasic", () => { - expect(CardFrontBackUtil.expand(CardType.SingleLineBasic, "A::B", DEFAULT_SETTINGS)).toEqual([ - new CardFrontBack("A", "B"), - ]); + expect(CardFrontBackUtil.expand(CardType.SingleLineBasic, "A::B", DEFAULT_SETTINGS)).toEqual([ + new CardFrontBack("A", "B"), + ]); }); test("CardType.SingleLineReversed", () => { - expect( - CardFrontBackUtil.expand(CardType.SingleLineReversed, "A:::B", DEFAULT_SETTINGS), - ).toEqual([new CardFrontBack("A", "B"), new CardFrontBack("B", "A")]); + expect( + CardFrontBackUtil.expand(CardType.SingleLineReversed, "A:::B", DEFAULT_SETTINGS), + ).toEqual([new CardFrontBack("A", "B"), new CardFrontBack("B", "A")]); }); describe("CardType.MultiLineBasic", () => { - test("Basic", () => { - expect( - CardFrontBackUtil.expand( - CardType.MultiLineBasic, - "A1\nA2\n?\nB1\nB2", - DEFAULT_SETTINGS, - ), - ).toEqual([new CardFrontBack("A1\nA2", "B1\nB2")]); - }); + test("Basic", () => { + expect( + CardFrontBackUtil.expand( + CardType.MultiLineBasic, + "A1\nA2\n?\nB1\nB2", + DEFAULT_SETTINGS, + ), + ).toEqual([new CardFrontBack("A1\nA2", "B1\nB2")]); + }); }); test("CardType.MultiLineReversed", () => { - expect( - CardFrontBackUtil.expand( - CardType.MultiLineReversed, - "A1\nA2\n??\nB1\nB2", - DEFAULT_SETTINGS, - ), - ).toEqual([new CardFrontBack("A1\nA2", "B1\nB2"), new CardFrontBack("B1\nB2", "A1\nA2")]); + expect( + CardFrontBackUtil.expand( + CardType.MultiLineReversed, + "A1\nA2\n??\nB1\nB2", + DEFAULT_SETTINGS, + ), + ).toEqual([new CardFrontBack("A1\nA2", "B1\nB2"), new CardFrontBack("B1\nB2", "A1\nA2")]); }); test("CardType.Cloze", () => { - const clozeFormatter = new QuestionTypeClozeFormatter(); + const clozeFormatter = new QuestionTypeClozeFormatter(); - expect( - CardFrontBackUtil.expand( - CardType.Cloze, - "This is a very ==interesting== test", - DEFAULT_SETTINGS, - ), - ).toEqual([ - new CardFrontBack( - "This is a very " + clozeFormatter.asking() + " test", - "This is a very " + clozeFormatter.showingAnswer("interesting") + " test", - ), - ]); + expect( + CardFrontBackUtil.expand( + CardType.Cloze, + "This is a very ==interesting== test", + DEFAULT_SETTINGS, + ), + ).toEqual([ + new CardFrontBack( + "This is a very " + clozeFormatter.asking() + " test", + "This is a very " + clozeFormatter.showingAnswer("interesting") + " test", + ), + ]); - const settings2: SRSettings = DEFAULT_SETTINGS; - settings2.clozePatterns = [ - "==[123;;]answer[;;hint]==", - "**[123;;]answer[;;hint]**", - "{{[123;;]answer[;;hint]}}", - ]; + const settings2: SRSettings = DEFAULT_SETTINGS; + settings2.clozePatterns = [ + "==[123;;]answer[;;hint]==", + "**[123;;]answer[;;hint]**", + "{{[123;;]answer[;;hint]}}", + ]; - expect( - CardFrontBackUtil.expand(CardType.Cloze, "This is a very **interesting** test", settings2), - ).toEqual([ - new CardFrontBack( - "This is a very " + clozeFormatter.asking() + " test", - "This is a very " + clozeFormatter.showingAnswer("interesting") + " test", - ), - ]); + expect( + CardFrontBackUtil.expand(CardType.Cloze, "This is a very **interesting** test", settings2), + ).toEqual([ + new CardFrontBack( + "This is a very " + clozeFormatter.asking() + " test", + "This is a very " + clozeFormatter.showingAnswer("interesting") + " test", + ), + ]); - expect( - CardFrontBackUtil.expand(CardType.Cloze, "This is a very {{interesting}} test", settings2), - ).toEqual([ - new CardFrontBack( - "This is a very " + clozeFormatter.asking() + " test", - "This is a very " + clozeFormatter.showingAnswer("interesting") + " test", - ), - ]); + expect( + CardFrontBackUtil.expand(CardType.Cloze, "This is a very {{interesting}} test", settings2), + ).toEqual([ + new CardFrontBack( + "This is a very " + clozeFormatter.asking() + " test", + "This is a very " + clozeFormatter.showingAnswer("interesting") + " test", + ), + ]); - expect( - CardFrontBackUtil.expand( - CardType.Cloze, - "This is a really very {{interesting}} and ==fascinating== and **great** test", - settings2, - ), - ).toEqual([ - new CardFrontBack( - "This is a really very interesting and [...] and great test", - "This is a really very interesting and fascinating and great test", - ), - new CardFrontBack( - "This is a really very interesting and fascinating and [...] test", - "This is a really very interesting and fascinating and great test", - ), - new CardFrontBack( - "This is a really very [...] and fascinating and great test", - "This is a really very interesting and fascinating and great test", - ), - ]); + expect( + CardFrontBackUtil.expand( + CardType.Cloze, + "This is a really very {{interesting}} and ==fascinating== and **great** test", + settings2, + ), + ).toEqual([ + new CardFrontBack( + "This is a really very interesting and [...] and great test", + "This is a really very interesting and fascinating and great test", + ), + new CardFrontBack( + "This is a really very interesting and fascinating and [...] test", + "This is a really very interesting and fascinating and great test", + ), + new CardFrontBack( + "This is a really very [...] and fascinating and great test", + "This is a really very interesting and fascinating and great test", + ), + ]); +}); +describe("CardType.Callout", () => { + test("Basic", () => { + expect( + CardFrontBackUtil.expand( + CardType.Callout, + ">[!Question] F1\nB1\nB2", + DEFAULT_SETTINGS, + ), + ).toEqual([new CardFrontBack("F1", "B1\nB2")]); + }); + test("Default Fold Open", () => { + expect( + CardFrontBackUtil.expand( + CardType.Callout, + ">[!Question]+ F1\nB1\nB2", + DEFAULT_SETTINGS, + ), + ).toEqual([new CardFrontBack("F1", "B1\nB2")]); + }); + test("Default Fold Close", () => { + expect( + CardFrontBackUtil.expand( + CardType.Callout, + ">[!Question]- F1\nB1\nB2", + DEFAULT_SETTINGS, + ), + ).toEqual([new CardFrontBack("F1", "B1\nB2")]); + }); + test("Lowercase label", () => { + expect( + CardFrontBackUtil.expand( + CardType.Callout, + ">[!question]+ F1\nB1\nB2", + DEFAULT_SETTINGS, + ), + ).toEqual([new CardFrontBack("F1", "B1\nB2")]); + }); }); From a621d11156bdb12f4a2ed115237efaadd1a25926 Mon Sep 17 00:00:00 2001 From: currently-coding <165482223+currently-coding@users.noreply.github.com> Date: Sat, 28 Jun 2025 11:02:04 +0200 Subject: [PATCH 08/10] removed console.log and added comments --- src/parser.ts | 374 +++++++++++++++++++++++++------------------------- 1 file changed, 186 insertions(+), 188 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index 8e730dcc..202d2e97 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -5,74 +5,71 @@ import { CardType } from "src/question"; export let debugParser = false; export interface ParserOptions { - singleLineCardSeparator: string; - singleLineReversedCardSeparator: string; - multilineCardSeparator: string; - multilineReversedCardSeparator: string; - multilineCardEndMarker: string; - clozePatterns: string[]; - calloutCardMarker: string; - calloutLineMarker: string; + singleLineCardSeparator: string; + singleLineReversedCardSeparator: string; + multilineCardSeparator: string; + multilineReversedCardSeparator: string; + multilineCardEndMarker: string; + clozePatterns: string[]; + calloutCardMarker: string; + calloutLineMarker: string; } export function setDebugParser(value: boolean) { - debugParser = value; + debugParser = value; } export class ParsedQuestionInfo { - cardType: CardType; - text: string; - - // Line numbers start at 0 - firstLineNum: number; - lastLineNum: number; - - constructor(cardType: CardType, text: string, firstLineNum: number, lastLineNum: number) { - console.log( - "New Card(" + cardType + "):\n\t" + text + "\n\t" + firstLineNum + " -> " + lastLineNum, - ); - this.cardType = cardType; - this.text = text; - this.firstLineNum = firstLineNum; - this.lastLineNum = lastLineNum; - } - - isQuestionLineNum(lineNum: number): boolean { - return lineNum >= this.firstLineNum && lineNum <= this.lastLineNum; - } + cardType: CardType; + text: string; + + // Line numbers start at 0 + firstLineNum: number; + lastLineNum: number; + + constructor(cardType: CardType, text: string, firstLineNum: number, lastLineNum: number) { + this.cardType = cardType; + this.text = text; + this.firstLineNum = firstLineNum; + this.lastLineNum = lastLineNum; + } + + isQuestionLineNum(lineNum: number): boolean { + return lineNum >= this.firstLineNum && lineNum <= this.lastLineNum; + } } function markerInsideCodeBlock(text: string, marker: string, markerIndex: number): boolean { - let goingBack = markerIndex - 1, - goingForward = markerIndex + marker.length; - let backTicksBefore = 0, - backTicksAfter = 0; - - while (goingBack >= 0) { - if (text[goingBack] === "`") backTicksBefore++; - goingBack--; - } - - while (goingForward < text.length) { - if (text[goingForward] === "`") backTicksAfter++; - goingForward++; - } - - // If there's an odd number of backticks before and after, - // the marker is inside an inline code block - return backTicksBefore % 2 === 1 && backTicksAfter % 2 === 1; + let goingBack = markerIndex - 1, + goingForward = markerIndex + marker.length; + let backTicksBefore = 0, + backTicksAfter = 0; + + while (goingBack >= 0) { + if (text[goingBack] === "`") backTicksBefore++; + goingBack--; + } + + while (goingForward < text.length) { + if (text[goingForward] === "`") backTicksAfter++; + goingForward++; + } + + // If there's an odd number of backticks before and after, + // the marker is inside an inline code block + return backTicksBefore % 2 === 1 && backTicksAfter % 2 === 1; } function hasInlineMarker(text: string, marker: string): boolean { - // No marker provided - if (marker.length == 0) return false; + // No marker provided + if (marker.length == 0) return false; - // Check if the marker is in the text - const markerIdx = text.indexOf(marker); - if (markerIdx === -1) return false; + // Check if the marker is in the text + const markerIdx = text.indexOf(marker); + if (markerIdx === -1) return false; - // Check if it's inside an inline code block - return !markerInsideCodeBlock(text, marker, markerIdx); + // Check if it's inside an inline code block + return !markerInsideCodeBlock(text, marker, markerIdx); } /** @@ -85,138 +82,139 @@ function hasInlineMarker(text: string, marker: string): boolean { * @returns An array of parsed question information */ export function parse(text: string, options: ParserOptions): ParsedQuestionInfo[] { - if (debugParser) { - console.log("Text to parse:\n<<<" + text + ">>>"); - } - - // Sort inline separators by length, longest first - const inlineSeparators = [ - { separator: options.singleLineCardSeparator, type: CardType.SingleLineBasic }, - { separator: options.singleLineReversedCardSeparator, type: CardType.SingleLineReversed }, - ]; - inlineSeparators.sort((a, b) => b.separator.length - a.separator.length); - - const cards: ParsedQuestionInfo[] = []; - let cardText = ""; - let cardType: CardType | null = null; - let firstLineNo = 0, - lastLineNo = 0; - - const clozecrafter = new ClozeCrafter(options.clozePatterns); - const lines: string[] = text.replaceAll("\r\n", "\n").split("\n"); - for (let i = 0; i < lines.length; i++) { - const currentLine = lines[i], - currentTrimmed = lines[i].trim(); - - // Skip everything in HTML comments - if (currentLine.startsWith("")) i++; - i++; - continue; - } - - // Have we reached the end of a card? - const isEmptyLine = currentTrimmed.length == 0; - const hasMultilineCardEndMarker = - options.multilineCardEndMarker && currentTrimmed == options.multilineCardEndMarker; - if ( - // We've probably reached the end of a card - (isEmptyLine && !options.multilineCardEndMarker) || - // Empty line AND (we're not picking up any card OR callout is done) - (isEmptyLine && (cardType == null || cardType == CardType.Callout)) || - // We've reached the end of a multi line card & - // we're using custom end markers - hasMultilineCardEndMarker - // || (cardType == CardType.Callout && !currentTrimmed.startsWith(options.calloutLineMarker)) - ) { - if (cardType) { - // Create a new card - lastLineNo = i - 1; - cards.push( - new ParsedQuestionInfo(cardType, cardText.trimEnd(), firstLineNo, lastLineNo), - ); - cardType = null; - } - - cardText = ""; - firstLineNo = i + 1; - continue; - } - - // Update card text - if (cardText.length > 0) { - cardText += "\n"; - } - cardText += currentLine.trimEnd(); - - // Pick up inline cards - for (const { separator, type } of inlineSeparators) { - if (hasInlineMarker(currentLine, separator)) { - cardType = type; - break; - } - } - - if (cardType == CardType.SingleLineBasic || cardType == CardType.SingleLineReversed) { - cardText = currentLine; - firstLineNo = i; - - // Pick up scheduling information if present - if (i + 1 < lines.length && lines[i + 1].startsWith("")) i++; + i++; + continue; + } + + // Have we reached the end of a card? + const isEmptyLine = currentTrimmed.length == 0; + const hasMultilineCardEndMarker = + options.multilineCardEndMarker && currentTrimmed == options.multilineCardEndMarker; + if ( + // We've probably reached the end of a card + (isEmptyLine && !options.multilineCardEndMarker) || + // Empty line AND (we're not picking up any card OR callout is done) + (isEmptyLine && (cardType == null || cardType == CardType.Callout)) || + // We've reached the end of a multi line card & + // we're using custom end markers + hasMultilineCardEndMarker + ) { + if (cardType) { + // Create a new card + lastLineNo = i - 1; + cards.push( + new ParsedQuestionInfo(cardType, cardText.trimEnd(), firstLineNo, lastLineNo), + ); + cardType = null; + } + + cardText = ""; + firstLineNo = i + 1; + continue; + } + + // Update card text + if (cardText.length > 0) { + cardText += "\n"; + } + cardText += currentLine.trimEnd(); + + // Pick up inline cards + for (const { separator, type } of inlineSeparators) { + if (hasInlineMarker(currentLine, separator)) { + cardType = type; + break; + } + } + + if (cardType == CardType.SingleLineBasic || cardType == CardType.SingleLineReversed) { + cardText = currentLine; + firstLineNo = i; + + // Pick up scheduling information if present + if (i + 1 < lines.length && lines[i + 1].startsWith("")) i++; - i++; - continue; - } - - // Have we reached the end of a card? - const isEmptyLine = currentTrimmed.length == 0; - const hasMultilineCardEndMarker = - options.multilineCardEndMarker && currentTrimmed == options.multilineCardEndMarker; - if ( - // We've probably reached the end of a card - (isEmptyLine && !options.multilineCardEndMarker) || - // Empty line AND (we're not picking up any card OR callout is done) - (isEmptyLine && (cardType == null || cardType == CardType.Callout)) || - // We've reached the end of a multi line card & - // we're using custom end markers - hasMultilineCardEndMarker - ) { - if (cardType) { - // Create a new card - lastLineNo = i - 1; - cards.push( - new ParsedQuestionInfo(cardType, cardText.trimEnd(), firstLineNo, lastLineNo), - ); - cardType = null; - } - - cardText = ""; - firstLineNo = i + 1; - continue; - } - - // Update card text - if (cardText.length > 0) { - cardText += "\n"; - } - cardText += currentLine.trimEnd(); - - // Pick up inline cards - for (const { separator, type } of inlineSeparators) { - if (hasInlineMarker(currentLine, separator)) { - cardType = type; - break; - } - } - - if (cardType == CardType.SingleLineBasic || cardType == CardType.SingleLineReversed) { - cardText = currentLine; - firstLineNo = i; - - // Pick up scheduling information if present - if (i + 1 < lines.length && lines[i + 1].startsWith("")) i++; + i++; + continue; + } + + // Have we reached the end of a card? + const isEmptyLine = currentTrimmed.length == 0; + const hasMultilineCardEndMarker = + options.multilineCardEndMarker && currentTrimmed == options.multilineCardEndMarker; + if ( + // We've probably reached the end of a card + (isEmptyLine && !options.multilineCardEndMarker) || + // Empty line AND (we're not picking up any card OR callout is done) + (isEmptyLine && (cardType == null || cardType == CardType.Callout)) || + // We've reached the end of a multi line card & + // we're using custom end markers + hasMultilineCardEndMarker + ) { + if (cardType) { + // Create a new card + lastLineNo = i - 1; + cards.push( + new ParsedQuestionInfo(cardType, cardText.trimEnd(), firstLineNo, lastLineNo), + ); + cardType = null; + } + + cardText = ""; + firstLineNo = i + 1; + continue; + } + + // Update card text + if (cardText.length > 0) { + cardText += "\n"; + } + cardText += currentLine.trimEnd(); + + // Pick up inline cards + for (const { separator, type } of inlineSeparators) { + if (hasInlineMarker(currentLine, separator)) { + cardType = type; + break; + } + } + + if (cardType == CardType.SingleLineBasic || cardType == CardType.SingleLineReversed) { + cardText = currentLine; + firstLineNo = i; + + // Pick up scheduling information if present + if (i + 1 < lines.length && lines[i + 1].startsWith("