From ef04893eba854c6d77c9fccd04476128789fcca5 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Wed, 10 Jun 2026 19:49:21 +1000 Subject: [PATCH] fix status trigger value residue --- docs/releases/unreleased.md | 4 + src/services/NaturalLanguageParser.ts | 80 ++++++++++++++++++- ...alLanguageParser.status-extraction.test.ts | 13 +++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a0..e16beba3 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Fixed + +- (#2007) Fixed a remaining task creation autocomplete case where a status value containing another status label could leave status fragments in the task title. Thanks to @prepare4robots for the follow-up report. diff --git a/src/services/NaturalLanguageParser.ts b/src/services/NaturalLanguageParser.ts index bc925f2a..f6be5da3 100644 --- a/src/services/NaturalLanguageParser.ts +++ b/src/services/NaturalLanguageParser.ts @@ -28,6 +28,7 @@ function getDateLocaleFromPlugin(plugin: TaskNotesPlugin): string { */ export class NaturalLanguageParser extends NaturalLanguageParserCore { private readonly taskNotesNlpTriggers?: NLPTriggersConfig; + private readonly taskNotesStatusConfigs: StatusConfig[]; private readonly taskNotesUserFields: UserMappedField[]; private readonly taskNotesPriorityConfigs: PriorityConfig[]; @@ -63,17 +64,87 @@ export class NaturalLanguageParser extends NaturalLanguageParserCore { options ); this.taskNotesNlpTriggers = nlpTriggers; + this.taskNotesStatusConfigs = statusConfigs; this.taskNotesUserFields = userFields || []; this.taskNotesPriorityConfigs = priorityConfigs; } parseInput(input: string): ParsedTaskData { const parsed = super.parseInput(input); - const withPriorityShortcutResidueRemoved = this.removePriorityShortcutResidue(input, parsed); + const withStatusShortcutResidueRemoved = this.applyTriggeredStatusMatch(input, parsed); + const withPriorityShortcutResidueRemoved = this.removePriorityShortcutResidue( + input, + withStatusShortcutResidueRemoved + ); const withLinkedFields = this.extractLinkedUserFields(input, withPriorityShortcutResidueRemoved); return this.normalizeUserFieldValues(withLinkedFields); } + private applyTriggeredStatusMatch(input: string, parsed: ParsedTaskData): ParsedTaskData { + const match = this.findTriggeredStatusMatch(input); + if (!match) { + return parsed; + } + + parsed.status = match.config.value; + + let title = parsed.title; + for (const residue of this.getStatusShortcutResidues(match.token)) { + title = this.removeTokenFragment(title, residue); + } + + parsed.title = title || "Untitled Task"; + return parsed; + } + + private findTriggeredStatusMatch( + input: string + ): { config: StatusConfig; token: string } | null { + const trigger = this.getStatusTrigger(); + if (!trigger || this.taskNotesStatusConfigs.length === 0) { + return null; + } + + const candidates: Array<{ config: StatusConfig; token: string }> = []; + for (const config of this.taskNotesStatusConfigs) { + for (const phrase of this.getStatusPhrases(config)) { + candidates.push({ config, token: `${trigger}${phrase}${trigger}` }); + candidates.push({ config, token: `${trigger}${phrase}` }); + } + } + + candidates.sort((a, b) => b.token.length - a.token.length); + return candidates.find((candidate) => this.containsToken(input, candidate.token)) ?? null; + } + + private getStatusShortcutResidues(token: string): string[] { + const residues = new Set([token]); + + for (const config of this.taskNotesStatusConfigs) { + for (const phrase of this.getStatusPhrases(config)) { + const residue = this.removeFirstCaseInsensitive(token, phrase); + if (residue && residue !== token) { + this.addStatusShortcutResidue(residues, residue); + } + } + } + + return Array.from(residues) + .filter((residue) => residue.trim().length > 0) + .sort((a, b) => b.length - a.length); + } + + private addStatusShortcutResidue(residues: Set, residue: string): void { + residues.add(residue); + residues.add(residue.replace(/\s+/g, " ").trim()); + } + + private getStatusPhrases(config: StatusConfig): string[] { + return [config.value, config.label] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((value) => value.trim()); + } + private removePriorityShortcutResidue(input: string, parsed: ParsedTaskData): ParsedTaskData { if (!parsed.priority || this.taskNotesPriorityConfigs.length === 0) { return parsed; @@ -122,6 +193,13 @@ export class NaturalLanguageParser extends NaturalLanguageParserCore { return priorityTrigger?.trigger || ""; } + private getStatusTrigger(): string { + const statusTrigger = this.taskNotesNlpTriggers?.triggers.find( + (trigger) => trigger.propertyId === "status" + ); + return statusTrigger?.enabled === false ? "" : statusTrigger?.trigger || "*"; + } + private containsToken(text: string, token: string): boolean { const escaped = this.escapeRegexLiteral(token); return new RegExp(`(^|\\s)${escaped}(?=\\s|$)`, "iu").test(text); diff --git a/tests/unit/services/NaturalLanguageParser.status-extraction.test.ts b/tests/unit/services/NaturalLanguageParser.status-extraction.test.ts index 087b585c..21273e20 100644 --- a/tests/unit/services/NaturalLanguageParser.status-extraction.test.ts +++ b/tests/unit/services/NaturalLanguageParser.status-extraction.test.ts @@ -277,6 +277,19 @@ describe('NaturalLanguageParser - Status Extraction', () => { expect(result.title).toBe('Task'); }); + it('prefers the full triggered status value when it contains a shorter status label', () => { + const statusConfigs: StatusConfig[] = [ + { id: 'resource', value: '30 Resource', label: 'Resource', color: '#666666', isCompleted: false, order: 1, autoArchive: false, autoArchiveDelay: 0 }, + { id: 'person', value: '31 Resource - Person', label: 'Person', color: '#0066cc', isCompleted: false, order: 2, autoArchive: false, autoArchiveDelay: 0 } + ]; + const parser = new NaturalLanguageParser(statusConfigs, priorityConfigs, false); + + const result = parser.parseInput('Test *31 Resource - Person*'); + + expect(result.status).toBe('31 Resource - Person'); + expect(result.title).toBe('Test'); + }); + it('should extract status without trigger (fallback) and remove it', () => { const statusConfigs: StatusConfig[] = [ { id: 'done', value: 'done', label: 'Done', color: '#00aa00', isCompleted: true, order: 1, autoArchive: false, autoArchiveDelay: 0 }