Add NLP literal quote escapes

This commit is contained in:
callumalpass 2026-05-17 07:12:41 +10:00
parent bef9aa6eac
commit c8bb7f5c64
4 changed files with 143 additions and 2 deletions

View file

@ -33,6 +33,7 @@ Example:
- ([#1541](https://github.com/callumalpass/tasknotes/issues/1541)) Added `{{currentNotePath}}` and `{{currentNoteTitle}}` support to the default tasks folder, so normal task creation can place new tasks beside the active note without creating an inline link. Thanks to @ChristianAnyanwu for suggesting this, and to @abbasou and @ttlaylor for the follow-up feedback.
- ([#648](https://github.com/callumalpass/tasknotes/issues/648), [#1605](https://github.com/callumalpass/tasknotes/issues/1605)) Added clickable links in task-card contexts for note links, markdown links, and web URLs while keeping plain contexts as tag-search buttons. Thanks to @trdischat and @Glint-Eye for suggesting this, and to @renatomen for the follow-up feedback.
- ([#1482](https://github.com/callumalpass/tasknotes/issues/1482)) Added clickable custom-field links on task cards for wikilinks, markdown links, autolinks, and bare web URLs. Thanks to @ptbosch-figueiredorj for suggesting this, and to @Soleone for the follow-up use case.
- ([#1475](https://github.com/callumalpass/tasknotes/issues/1475), [#725](https://github.com/callumalpass/tasknotes/issues/725)) Added quote/backtick escapes for NLP input, so literal course codes or date words can stay in task titles without being parsed as estimates or dates. Thanks to @RumiaKitinari and @gavingwebb for suggesting this.
- ([#1603](https://github.com/callumalpass/tasknotes/issues/1603)) Added Calendar view toggles for hiding completed or skipped recurring task instances while keeping future outstanding instances visible. Thanks to @wandererovertheseaofpiss for suggesting this.
- ([#1625](https://github.com/callumalpass/tasknotes/issues/1625)) Added `Shift` + `Cmd`/`Ctrl` + `Enter` in the Create Task modal to save the current task and reopen the modal for the next one. Thanks to @tcb678 for suggesting this.
- ([#1641](https://github.com/callumalpass/tasknotes/issues/1641)) Added support for list-valued property-based Calendar start and end dates, so one note can render multiple property events without extra view settings. Thanks to @jhoogeboom for suggesting this.

View file

@ -17,6 +17,8 @@ Body template settings let you scaffold newly created tasks with consistent note
NLP settings define how text input is interpreted during task capture. **Enable natural language task input** activates date and metadata parsing, **Default to scheduled** changes ambiguous date handling, **NLP language** selects parsing patterns, and **Status suggestion trigger** controls optional status autocomplete activation.
Wrap text in double quotes, single quotes, or backticks to keep it as literal title text during NLP parsing. For example, `BIO "123H" - HW1` keeps `123H` in the title instead of treating it as a 123-hour estimate, while `Review "Today" notes` keeps `Today` as text instead of parsing it as a date.
## Pomodoro Timer
Pomodoro settings control interval lengths, long-break cadence, optional auto-start behavior, and end-of-session notifications/sound. **Pomodoro data storage** chooses whether history is kept in plugin data or daily notes.

View file

@ -49,8 +49,94 @@ export class NaturalLanguageParser extends NaturalLanguageParserCore {
}
parseInput(input: string): ParsedTaskData {
const parsed = super.parseInput(input);
return this.normalizeUserFieldValues(this.extractLinkedUserFields(input, parsed));
const protectedInput = this.protectQuotedLiterals(input);
const parsed = super.parseInput(protectedInput.text);
const withLinkedFields = this.extractLinkedUserFields(protectedInput.text, parsed);
this.restoreQuotedLiterals(withLinkedFields, protectedInput.literals);
return this.normalizeUserFieldValues(withLinkedFields);
}
private protectQuotedLiterals(input: string): { text: string; literals: string[] } {
const literals: string[] = [];
let text = "";
let index = 0;
while (index < input.length) {
const char = input[index];
if (!this.isQuoteDelimiter(char) || !this.isEligibleQuoteStart(input, index)) {
text += char;
index += 1;
continue;
}
const endIndex = this.findClosingQuote(input, index + 1, char);
if (endIndex === -1 || !this.isEligibleQuoteEnd(input, endIndex)) {
text += char;
index += 1;
continue;
}
const literal = input.slice(index + 1, endIndex);
if (literal.trim().length === 0) {
text += char;
index += 1;
continue;
}
const placeholder = `__TASKNOTES_LITERAL_${literals.length}__`;
literals.push(literal);
text += placeholder;
index = endIndex + 1;
}
return { text, literals };
}
private restoreQuotedLiterals(parsed: ParsedTaskData, literals: string[]): void {
if (literals.length === 0) return;
let title = parsed.title;
literals.forEach((literal, index) => {
title = title.replace(`__TASKNOTES_LITERAL_${index}__`, literal);
});
parsed.title = title.replace(/\s+/g, " ").trim();
}
private isQuoteDelimiter(char: string): boolean {
return char === "\"" || char === "'" || char === "`";
}
private isEligibleQuoteStart(input: string, index: number): boolean {
const char = input[index];
if (char !== "'") return true;
const previous = index > 0 ? input[index - 1] : "";
return !this.isLiteralBoundaryWordCharacter(previous);
}
private isEligibleQuoteEnd(input: string, index: number): boolean {
const char = input[index];
if (char !== "'") return true;
const next = index + 1 < input.length ? input[index + 1] : "";
return !this.isLiteralBoundaryWordCharacter(next);
}
private findClosingQuote(input: string, startIndex: number, delimiter: string): number {
for (let index = startIndex; index < input.length; index += 1) {
if (input[index] !== delimiter) continue;
const previous = index > 0 ? input[index - 1] : "";
if (previous === "\\") continue;
return index;
}
return -1;
}
private isLiteralBoundaryWordCharacter(char: string): boolean {
return /[\p{L}\p{N}_]/u.test(char);
}
private normalizeUserFieldValues(parsed: ParsedTaskData): ParsedTaskData {

View file

@ -0,0 +1,52 @@
/**
* @see https://github.com/callumalpass/tasknotes/issues/1475
* @see https://github.com/callumalpass/tasknotes/issues/725
*/
import { NaturalLanguageParser } from "../../../src/services/NaturalLanguageParser";
function createParser(): NaturalLanguageParser {
return new NaturalLanguageParser([], [], true, "en");
}
describe("issues #1475 and #725: NLP literal quote escapes", () => {
it("keeps quoted course-code hours in the title instead of parsing a time estimate", () => {
const result = createParser().parseInput('BIO "123H" - HW1');
expect(result.title).toBe("BIO 123H - HW1");
expect(result.estimate).toBeUndefined();
});
it("keeps quoted date words in the title instead of parsing dates", () => {
const result = createParser().parseInput('Something "Today"');
expect(result.title).toBe("Something Today");
expect(result.scheduledDate).toBeUndefined();
expect(result.dueDate).toBeUndefined();
});
it("supports backtick and single-quote literal spans", () => {
const backtickResult = createParser().parseInput("Read `tomorrow` magazine");
const singleQuoteResult = createParser().parseInput("Review 'Today is the Day' book");
expect(backtickResult.title).toBe("Read tomorrow magazine");
expect(backtickResult.scheduledDate).toBeUndefined();
expect(singleQuoteResult.title).toBe("Review Today is the Day book");
expect(singleQuoteResult.scheduledDate).toBeUndefined();
});
it("still parses unquoted NLP outside literal spans", () => {
const result = createParser().parseInput('Review "BIO 123H" tomorrow 2h');
expect(result.title).toBe("Review BIO 123H");
expect(result.scheduledDate).toBeDefined();
expect(result.estimate).toBe(120);
});
it("leaves apostrophes in normal words alone", () => {
const result = createParser().parseInput("John's task tomorrow");
expect(result.title).toBe("John's task");
expect(result.scheduledDate).toBeDefined();
});
});