diff --git a/src/note-question-parser.ts b/src/note-question-parser.ts index 491aca80..f4c1d24f 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.calloutLineMarker, }; // 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..b10ef399 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -11,6 +11,8 @@ export interface ParserOptions { multilineReversedCardSeparator: string; multilineCardEndMarker: string; clozePatterns: string[]; + calloutCardMarker: string[]; + calloutLineMarker: string; } export function setDebugParser(value: boolean) { @@ -117,8 +119,8 @@ export function parse(text: string, options: ParserOptions): ParsedQuestionInfo[ 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) || + // 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 @@ -190,6 +192,22 @@ export function parse(text: string, options: ParserOptions): ParsedQuestionInfo[ } else if (cardType === null && clozecrafter.isClozeNote(currentLine)) { // Pick up cloze cards cardType = CardType.Cloze; + } else if ( + cardType === null && + options.calloutCardMarker.length != 0 && + // no marker is empty + options.calloutCardMarker.every((marker) => marker.trim().length > 0) && + // at least one marker matches the start of the current line + options.calloutCardMarker.some((marker) => + currentLine.trim().toLowerCase().startsWith(marker.toLowerCase()), + ) + ) { + // remove all lines before the question(=current line) from cardText + const split = cardText.split("\n"); + cardText = split[split.length - 1]; + // increase firstLineNo accordingly + firstLineNo += split.length - 1; + cardType = CardType.Callout; } } diff --git a/src/question-type.ts b/src/question-type.ts index b11c2174..a4c91ce1 100644 --- a/src/question-type.ts +++ b/src/question-type.ts @@ -105,11 +105,29 @@ class QuestionTypeCloze implements IQuestionTypeHandler { back = clozeNote.getCardBack(i, clozeFormatter); result.push(new CardFrontBack(front, back)); } - return result; } } +class CalloutType implements IQuestionTypeHandler { + expand(questionText: string, settings: SRSettings): CardFrontBack[] { + const questionLines = questionText.split("\n"); + // assume the line is supposed to become a flashcard and thus has a valid `calloutCardMarker` + // then deleting everything in the `[]` brackets suffices to delete the marker + const regex = new RegExp("^>\\[.*?\\][+-]?", "i"); + // question is always in the first line + const side1: string = questionLines[0].replace(regex, "").trim(); + const side2 = questionLines + .slice(1) + // remove first level of `calloutLineMarker` + .map((line) => line.replace(settings.calloutLineMarker, "")) + .join("\n") + .trim(); + + const result: CardFrontBack[] = [new CardFrontBack(side1, side2)]; + return result; + } +} export class QuestionTypeClozeFormatter implements IClozeFormatter { asking(answer?: string, hint?: string): string { return `${!hint ? "[...]" : `[${hint}]`}`; @@ -143,6 +161,9 @@ export class QuestionTypeFactory { case CardType.Cloze: handler = new QuestionTypeCloze(); break; + case CardType.Callout: + handler = new CalloutType(); + break; } return handler; } diff --git a/src/question.ts b/src/question.ts index 9ce63592..54434f95 100644 --- a/src/question.ts +++ b/src/question.ts @@ -17,6 +17,7 @@ export enum CardType { MultiLineBasic, MultiLineReversed, Cloze, + Callout, } // QuestionText comprises the following components: diff --git a/src/settings.ts b/src/settings.ts index d2c8a3da..694ea6e7 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -22,6 +22,8 @@ export interface SRSettings { multilineCardSeparator: string; multilineReversedCardSeparator: string; multilineCardEndMarker: string; + calloutCardMarker: string[]; + calloutLineMarker: string; editLaterTag: string; // notes @@ -82,6 +84,8 @@ export const DEFAULT_SETTINGS: SRSettings = { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutCardMarker: [">[!Question]", ">[!Definition]", ">[!Card]"], + calloutLineMarker: ">", editLaterTag: "#edit-later", // notes diff --git a/tests/unit/parser.test.ts b/tests/unit/parser.test.ts index be286bd6..31da2d1d 100644 --- a/tests/unit/parser.test.ts +++ b/tests/unit/parser.test.ts @@ -8,6 +8,8 @@ const parserOptions: ParserOptions = { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [ "==[123;;]answer[;;hint]==", "**[123;;]answer[;;hint]**", @@ -60,6 +62,8 @@ test("Test parsing of single line basic cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([[CardType.SingleLineBasic, "Question&&Answer", 0, 0]]); @@ -70,6 +74,8 @@ test("Test parsing of single line basic cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([[CardType.SingleLineBasic, "Question=Answer", 0, 0]]); @@ -82,6 +88,8 @@ test("Test parsing of single line basic cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([]); @@ -108,6 +116,8 @@ test("Test parsing of single line reversed cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([[CardType.SingleLineReversed, "Question&&&Answer", 0, 0]]); @@ -118,6 +128,8 @@ test("Test parsing of single line reversed cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([[CardType.SingleLineReversed, "Question::Answer", 0, 0]]); @@ -128,6 +140,8 @@ test("Test parsing of single line reversed cards", () => { multilineCardSeparator: ";>", multilineReversedCardSeparator: "<;>", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([ @@ -143,6 +157,8 @@ test("Test parsing of single line reversed cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([]); @@ -182,6 +198,8 @@ test("Test parsing of multi line basic cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["**[123;;]answer[;;hint]**"], }), ).toEqual([[CardType.MultiLineBasic, "Question\n?\nAnswer line 1\nAnswer line 2", 0, 4]]); @@ -194,6 +212,8 @@ test("Test parsing of multi line basic cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["**[123;;]answer[;;hint]**"], }, ), @@ -210,6 +230,8 @@ test("Test parsing of multi line basic cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["**[123;;]answer[;;hint]**"], }, ), @@ -231,6 +253,8 @@ test("Test parsing of multi line basic cards", () => { multilineCardSeparator: "@@", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([[CardType.MultiLineBasic, "Question\n@@\nAnswer", 0, 2]]); @@ -243,6 +267,8 @@ test("Test parsing of multi line basic cards", () => { multilineCardSeparator: "", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([]); @@ -272,6 +298,8 @@ test("Test parsing of multi line reversed cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [ "==[123;;]answer[;;hint]==", "**[123;;]answer[;;hint]**", @@ -288,6 +316,8 @@ test("Test parsing of multi line reversed cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [ "==[123;;]answer[;;hint]==", "**[123;;]answer[;;hint]**", @@ -308,6 +338,8 @@ test("Test parsing of multi line reversed cards", () => { multilineCardSeparator: "@@", multilineReversedCardSeparator: "@@@", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([[CardType.MultiLineReversed, "Question\n@@@\nAnswer", 0, 2]]); @@ -338,6 +370,8 @@ Line 5 multilineCardSeparator: "??", multilineReversedCardSeparator: "???", multilineCardEndMarker: "????", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }, ), @@ -354,6 +388,8 @@ Line 5 multilineCardSeparator: "?", multilineReversedCardSeparator: "\t", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [], }), ).toEqual([]); @@ -395,6 +431,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["**[123;;]answer[;;hint]**", "{{[123;;]answer[;;hint]}}"], }), ).toEqual([]); @@ -434,6 +472,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["==[123;;]answer[;;hint]==", "{{[123;;]answer[;;hint]}}"], }), ).toEqual([]); @@ -474,6 +514,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["==[123;;]answer[;;hint]==", "**[123;;]answer[;;hint]**"], }), ).toEqual([]); @@ -488,6 +530,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["{{[123::]answer[::hint]}}"], }), ).toEqual([[CardType.Cloze, "Brazilians speak {{Portuguese::language}}", 0, 0]]); @@ -500,6 +544,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["{{[123::]answer[::hint]}}"], }, ), @@ -516,6 +562,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["{{[123::]answer[::hint]}}"], }, ), @@ -532,6 +580,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["==answer==[^\\[hint\\]][\\[^123\\]]"], }), ).toEqual([ @@ -547,6 +597,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["==answer==[^\\[hint\\]][\\[^123\\]]"], }, ), @@ -563,6 +615,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["==answer==[^\\[hint\\]][\\[^123\\]]"], }, ), @@ -584,6 +638,8 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "---", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: ["==[123;;]answer[;;hint]=="], }, ), @@ -610,6 +666,132 @@ test("Test parsing of cloze cards", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], + clozePatterns: [], + }), + ).toEqual([]); +}); + +test("Test parsing of callout cards", () => { + // standard symbols + expect(parseT(">[!Question] Question\n>Answer", 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\n\n\n", parserOptions)).toEqual([ + [CardType.Callout, ">[!Question] Question\n>Answer 1\n>Answer2", 0, 2], + ]); + expect( + parseT(">[!Question] Question\n>Answer ", 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]]); + // Mutli Symbols + expect( + parseT(">[!Definition] 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( + ">[!Definition] Question\n>Answer line 1\n>Answer line 2\n\n>[!Question] Question\n>Answer line 1\n>Answer line 2\n", + parserOptions, + ), + ).toEqual([ + [CardType.Callout, ">[!Definition] Question\n>Answer line 1\n>Answer line 2", 0, 2], + [CardType.Callout, ">[!Question] Question\n>Answer line 1\n>Answer line 2", 2, 4], + ]); + // 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( + ">[!Card] 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: [">[!Card]"], + clozePatterns: ["**[123;;]answer[;;hint]**"], + }, + ), + ).toEqual([ + [CardType.Callout, ">[!Card] Question 1\n>Answer line 1\n>Answer line 2", 0, 2], + [CardType.Callout, ">[!Card] 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([]); @@ -774,6 +956,8 @@ test("Test not parsing 'cards' in codeblocks", () => { multilineCardSeparator: "?", multilineReversedCardSeparator: "??", multilineCardEndMarker: "", + calloutLineMarker: ">", + calloutCardMarker: [">[!Question]", ">[!Definition]"], clozePatterns: [ "==[123;;]answer[;;hint]==", "**[123;;]answer[;;hint]**", diff --git a/tests/unit/question-type.test.ts b/tests/unit/question-type.test.ts index c8e78786..0315d99d 100644 --- a/tests/unit/question-type.test.ts +++ b/tests/unit/question-type.test.ts @@ -98,3 +98,37 @@ test("CardType.Cloze", () => { ), ]); }); +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")]); + }); +});