From de08bc83bfe31c78dd0314bbce211c69307f6ca3 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 29 Jan 2026 21:40:52 +0100 Subject: [PATCH] perf: lazy load ical.js and chrono-node to reduce initial bundle size Lazy load ical.js (~76 KB) in CalDAV client service using the existing loadIcalModule() pattern and lazy load chrono-node (~150 KB) in short-syntax.ts by making date parsing async. Reduces initial bundle from 5.54 MB to 5.42 MB (120 KB savings, well under 5.50 MB budget). --- .../providers/caldav/caldav-client.service.ts | 26 +- src/app/features/schedule/ical/exdate.spec.ts | 3 + .../get-relevant-events-from-ical.spec.ts | 3 + .../schedule/ical/ical-lazy-loader.spec.ts | 3 + .../add-task-bar-parser.service.spec.ts | 194 +++++----- .../add-task-bar-parser.service.ts | 6 +- .../add-task-bar/add-task-bar.component.ts | 56 +-- .../add-task-bar/short-syntax-to-tags.ts | 6 +- src/app/features/tasks/short-syntax.spec.ts | 350 +++++++++--------- src/app/features/tasks/short-syntax.ts | 104 +++--- .../tasks/store/short-syntax.effects.ts | 232 ++++++------ 11 files changed, 510 insertions(+), 473 deletions(-) diff --git a/src/app/features/issue/providers/caldav/caldav-client.service.ts b/src/app/features/issue/providers/caldav/caldav-client.service.ts index 89fdce938d..34c140160d 100644 --- a/src/app/features/issue/providers/caldav/caldav-client.service.ts +++ b/src/app/features/issue/providers/caldav/caldav-client.service.ts @@ -4,8 +4,7 @@ import { CaldavCfg } from './caldav.model'; import DavClient, { namespaces as NS } from '@nextcloud/cdav-library'; // @ts-ignore import Calendar from 'cdav-library/models/calendar'; -// @ts-ignore -import ICAL from 'ical.js'; +import { loadIcalModule } from '../../../schedule/ical/ical-lazy-loader'; import { from, Observable, throwError } from 'rxjs'; import { CaldavIssue, CaldavIssueStatus } from './caldav-issue.model'; @@ -122,7 +121,8 @@ export class CaldavClientService { return await calendar.calendarQuery([query]); } - private static _mapTask(task: CalDavTaskData): CaldavIssue { + private static async _mapTask(task: CalDavTaskData): Promise { + const ICAL = await loadIcalModule(); const jCal = ICAL.parse(task.data); const comp = new ICAL.Component(jCal); const todo = comp.getFirstSubcomponent('vtodo'); @@ -140,8 +140,8 @@ export class CaldavClientService { completed: !!todo.getFirstPropertyValue('completed'), item_url: task.url, summary: (todo.getFirstPropertyValue('summary') as string) || '', - start: (todo.getFirstPropertyValue('dtstart') as ICAL.Time)?.toJSDate().getTime(), - due: (todo.getFirstPropertyValue('due') as ICAL.Time)?.toJSDate().getTime(), + start: (todo.getFirstPropertyValue('dtstart') as any)?.toJSDate().getTime(), + due: (todo.getFirstPropertyValue('due') as any)?.toJSDate().getTime(), note: (todo.getFirstPropertyValue('description') as string) || undefined, status: (todo.getFirstPropertyValue('status') as CaldavIssueStatus) || undefined, priority: +(todo.getFirstPropertyValue('priority') as string) || undefined, @@ -347,12 +347,13 @@ export class CaldavClientService { const tasks = await CaldavClientService._getAllTodos(cal, filterOpen).catch((err) => this._handleNetErr(err), ); - return tasks - .map((t) => CaldavClientService._mapTask(t)) - .filter( - (t: CaldavIssue) => - !filterCategory || !cfg.categoryFilter || t.labels.includes(cfg.categoryFilter), - ); + const mappedTasks = await Promise.all( + tasks.map((t) => CaldavClientService._mapTask(t)), + ); + return mappedTasks.filter( + (t: CaldavIssue) => + !filterCategory || !cfg.categoryFilter || t.labels.includes(cfg.categoryFilter), + ); } private async _getTask(cfg: CaldavCfg, uid: string): Promise { @@ -369,7 +370,7 @@ export class CaldavClientService { throw new Error('ISSUE NOT FOUND: ' + uid); } - return CaldavClientService._mapTask(task[0]); + return await CaldavClientService._mapTask(task[0]); } private async _updateTask( @@ -406,6 +407,7 @@ export class CaldavClientService { } const task = tasks[0]; + const ICAL = await loadIcalModule(); const jCal = ICAL.parse(task.data); const comp = new ICAL.Component(jCal); const todo = comp.getFirstSubcomponent('vtodo'); diff --git a/src/app/features/schedule/ical/exdate.spec.ts b/src/app/features/schedule/ical/exdate.spec.ts index 7c8b34ac5e..c2d0d0d9e6 100644 --- a/src/app/features/schedule/ical/exdate.spec.ts +++ b/src/app/features/schedule/ical/exdate.spec.ts @@ -1,3 +1,6 @@ +// Eager import ensures ical.js is in the webpack test bundle, +// so the dynamic import() inside loadIcalModule() can resolve it. +import 'ical.js'; import { getRelevantEventsForCalendarIntegrationFromIcal } from './get-relevant-events-from-ical'; import { getDbDateStr } from '../../../util/get-db-date-str'; diff --git a/src/app/features/schedule/ical/get-relevant-events-from-ical.spec.ts b/src/app/features/schedule/ical/get-relevant-events-from-ical.spec.ts index 6735233dbf..331c575b50 100644 --- a/src/app/features/schedule/ical/get-relevant-events-from-ical.spec.ts +++ b/src/app/features/schedule/ical/get-relevant-events-from-ical.spec.ts @@ -1,3 +1,6 @@ +// Eager import ensures ical.js is in the webpack test bundle, +// so the dynamic import() inside loadIcalModule() can resolve it. +import 'ical.js'; import { getRelevantEventsForCalendarIntegrationFromIcal } from './get-relevant-events-from-ical'; describe('getRelevantEventsForCalendarIntegrationFromIcal', () => { diff --git a/src/app/features/schedule/ical/ical-lazy-loader.spec.ts b/src/app/features/schedule/ical/ical-lazy-loader.spec.ts index aee5d05186..c97257c97d 100644 --- a/src/app/features/schedule/ical/ical-lazy-loader.spec.ts +++ b/src/app/features/schedule/ical/ical-lazy-loader.spec.ts @@ -1,3 +1,6 @@ +// Eager import ensures ical.js is in the webpack test bundle, +// so the dynamic import() inside loadIcalModule() can resolve it. +import 'ical.js'; import { loadIcalModule } from './ical-lazy-loader'; describe('ical-lazy-loader', () => { diff --git a/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.spec.ts b/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.spec.ts index 3890ab6dc9..8b2c938134 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.spec.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.spec.ts @@ -93,18 +93,18 @@ describe('AddTaskBarParserService', () => { mockStateService.updateAttachments.calls.reset(); }); - it('should handle empty text', () => { - service.parseAndUpdateText('', null, [], [], null as any); + it('should handle empty text', async () => { + await service.parseAndUpdateText('', null, [], [], null as any); expect(mockStateService.updateCleanText).not.toHaveBeenCalled(); }); - it('should handle null config', () => { - service.parseAndUpdateText('test task', null, [], [], null as any); + it('should handle null config', async () => { + await service.parseAndUpdateText('test task', null, [], [], null as any); expect(mockStateService.updateCleanText).not.toHaveBeenCalled(); }); describe('Date Parsing', () => { - it('should handle default date when no date syntax present and no current state', () => { + it('should handle default date when no date syntax present and no current state', async () => { const defaultDate = '2024-01-15'; const defaultTime = '09:00'; @@ -124,7 +124,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Simple task', mockConfig, mockProjects, @@ -141,7 +141,7 @@ describe('AddTaskBarParserService', () => { expect(time).toBe(defaultTime); }); - it('should preserve current date/time when no syntax present', () => { + it('should preserve current date/time when no syntax present', async () => { const currentDate = '2024-02-20'; const currentTime = '14:30'; @@ -161,7 +161,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task without date syntax', mockConfig, mockProjects, @@ -175,7 +175,7 @@ describe('AddTaskBarParserService', () => { expect(time).toBe(currentTime); }); - it('should preserve current date but use default time when no current time', () => { + it('should preserve current date but use default time when no current time', async () => { const currentDate = '2024-02-20'; const defaultTime = '09:00'; @@ -195,7 +195,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task text', mockConfig, mockProjects, @@ -211,8 +211,8 @@ describe('AddTaskBarParserService', () => { expect(time).toBe(defaultTime); }); - it('should handle no date or default date', () => { - service.parseAndUpdateText( + it('should handle no date or default date', async () => { + await service.parseAndUpdateText( 'Simple task', mockConfig, mockProjects, @@ -226,10 +226,10 @@ describe('AddTaskBarParserService', () => { expect(time).toBeNull(); }); - it('should test date parsing integration with shortSyntax', () => { + it('should test date parsing integration with shortSyntax', async () => { // Since shortSyntax is complex and depends on external implementation, // we'll test the parser's handling of the parsed results - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task with date syntax that may or may not be parsed', mockConfig, mockProjects, @@ -241,11 +241,11 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateCleanText).toHaveBeenCalled(); }); - it('should handle default date and time when no syntax is found', () => { + it('should handle default date and time when no syntax is found', async () => { const defaultDate = '2024-01-15'; const defaultTime = '09:00'; - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Plain text task', mockConfig, mockProjects, @@ -263,8 +263,8 @@ describe('AddTaskBarParserService', () => { }); describe('Parsing Integration', () => { - it('should call updateEstimate when parsing text', () => { - service.parseAndUpdateText( + it('should call updateEstimate when parsing text', async () => { + await service.parseAndUpdateText( 'Task with potential time estimate', mockConfig, mockProjects, @@ -275,8 +275,8 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateEstimate).toHaveBeenCalled(); }); - it('should handle null time estimates', () => { - service.parseAndUpdateText( + it('should handle null time estimates', async () => { + await service.parseAndUpdateText( 'Simple task', mockConfig, mockProjects, @@ -287,8 +287,8 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateEstimate).toHaveBeenCalledWith(null); }); - it('should call updateSpent when parsing text', () => { - service.parseAndUpdateText( + it('should call updateSpent when parsing text', async () => { + await service.parseAndUpdateText( 'Task with potential time spent', mockConfig, mockProjects, @@ -299,8 +299,8 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateSpent).toHaveBeenCalled(); }); - it('should handle null time spent', () => { - service.parseAndUpdateText( + it('should handle null time spent', async () => { + await service.parseAndUpdateText( 'Simple task', mockConfig, mockProjects, @@ -313,8 +313,8 @@ describe('AddTaskBarParserService', () => { }); describe('Basic Parsing Tests', () => { - it('should update tags when parsing text', () => { - service.parseAndUpdateText( + it('should update tags when parsing text', async () => { + await service.parseAndUpdateText( 'Task with tags', mockConfig, mockProjects, @@ -326,10 +326,10 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateNewTagTitles).toHaveBeenCalled(); }); - it('should handle auto-detected projects', () => { + it('should handle auto-detected projects', async () => { mockStateService.isAutoDetected.and.returnValue(false); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Simple task', mockConfig, mockProjects, @@ -342,8 +342,8 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateDate).toHaveBeenCalled(); }); - it('should handle text with clean text update', () => { - service.parseAndUpdateText( + it('should handle text with clean text update', async () => { + await service.parseAndUpdateText( 'Task text', mockConfig, mockProjects, @@ -354,22 +354,22 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateCleanText).toHaveBeenCalledWith('Task text'); }); - it('should handle edge cases gracefully', () => { + it('should handle edge cases gracefully', async () => { const longText = 'Task ' + 'a'.repeat(1000); - expect(() => { + await expectAsync( service.parseAndUpdateText( longText, mockConfig, mockProjects, mockTags, mockDefaultProject, - ); - }).not.toThrow(); + ), + ).not.toBeRejected(); }); - it('should handle empty arrays for projects and tags', () => { - service.parseAndUpdateText( + it('should handle empty arrays for projects and tags', async () => { + await service.parseAndUpdateText( 'Task with no matching items', mockConfig, [], @@ -381,8 +381,8 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateNewTagTitles).toHaveBeenCalled(); }); - it('should handle special characters in task text', () => { - service.parseAndUpdateText( + it('should handle special characters in task text', async () => { + await service.parseAndUpdateText( 'Task with special chars !@#$%^&*()', mockConfig, mockProjects, @@ -396,42 +396,42 @@ describe('AddTaskBarParserService', () => { }); describe('resetPreviousResult', () => { - it('should reset previous result without error', () => { + it('should reset previous result without error', async () => { expect(() => service.resetPreviousResult()).not.toThrow(); }); }); describe('removeShortSyntaxFromInput', () => { - it('should return same input for empty string', () => { + it('should return same input for empty string', async () => { expect(service.removeShortSyntaxFromInput('', 'tags')).toBe(''); }); describe('tags removal', () => { - it('should remove specific tag', () => { + it('should remove specific tag', async () => { const input = 'Task #important #urgent'; const result = service.removeShortSyntaxFromInput(input, 'tags', 'important'); expect(result).toBe('Task #urgent'); }); - it('should remove all tags when no specific tag provided', () => { + it('should remove all tags when no specific tag provided', async () => { const input = 'Task #important #urgent #todo'; const result = service.removeShortSyntaxFromInput(input, 'tags'); expect(result).toBe('Task'); }); - it('should handle tag removal case insensitively', () => { + it('should handle tag removal case insensitively', async () => { const input = 'Task #Important #URGENT'; const result = service.removeShortSyntaxFromInput(input, 'tags', 'important'); expect(result).toBe('Task #URGENT'); }); - it('should handle tags at the beginning of input', () => { + it('should handle tags at the beginning of input', async () => { const input = '#urgent Task content'; const result = service.removeShortSyntaxFromInput(input, 'tags', 'urgent'); expect(result).toBe('Task content'); }); - it('should handle tags at the end of input', () => { + it('should handle tags at the end of input', async () => { const input = 'Task content #urgent'; const result = service.removeShortSyntaxFromInput(input, 'tags', 'urgent'); expect(result).toBe('Task content'); @@ -439,25 +439,25 @@ describe('AddTaskBarParserService', () => { }); describe('date removal', () => { - it('should remove date syntax', () => { + it('should remove date syntax', async () => { const input = 'Task @today @16:30 @2024-01-15'; const result = service.removeShortSyntaxFromInput(input, 'date'); expect(result).toBe('Task'); }); - it('should handle complex date formats', () => { + it('should handle complex date formats', async () => { const input = 'Meeting @tomorrow @next-week @2024-12-25'; const result = service.removeShortSyntaxFromInput(input, 'date'); expect(result).toBe('Meeting'); }); - it('should handle date at beginning', () => { + it('should handle date at beginning', async () => { const input = '@today Task content'; const result = service.removeShortSyntaxFromInput(input, 'date'); expect(result).toBe('Task content'); }); - it('should handle date at end', () => { + it('should handle date at end', async () => { const input = 'Task content @today'; const result = service.removeShortSyntaxFromInput(input, 'date'); expect(result).toBe('Task content'); @@ -465,7 +465,7 @@ describe('AddTaskBarParserService', () => { }); describe('estimate removal', () => { - it('should remove various time estimate formats', () => { + it('should remove various time estimate formats', async () => { const testCases = [ { input: 'Task t30m', expected: 'Task' }, { input: 'Task 1h', expected: 'Task' }, @@ -484,63 +484,63 @@ describe('AddTaskBarParserService', () => { }); }); - it('should handle multiple time estimates', () => { + it('should handle multiple time estimates', async () => { const input = 'Task t30m another t1h final'; const result = service.removeShortSyntaxFromInput(input, 'estimate'); expect(result).toBe('Task another final'); }); - it('should handle time estimate at beginning', () => { + it('should handle time estimate at beginning', async () => { const input = 't30m Task content'; const result = service.removeShortSyntaxFromInput(input, 'estimate'); expect(result).toBe('Task content'); }); - it('should handle time estimate at end', () => { + it('should handle time estimate at end', async () => { const input = 'Task content t30m'; const result = service.removeShortSyntaxFromInput(input, 'estimate'); expect(result).toBe('Task content'); }); - it('should handle decimal hours', () => { + it('should handle decimal hours', async () => { const input = 'Task t2.5h content'; const result = service.removeShortSyntaxFromInput(input, 'estimate'); expect(result).toBe('Task content'); }); - it('should handle days format', () => { + it('should handle days format', async () => { const input = 'Task 3d content'; const result = service.removeShortSyntaxFromInput(input, 'estimate'); expect(result).toBe('Task content'); }); }); - it('should clean up extra whitespace', () => { + it('should clean up extra whitespace', async () => { const input = 'Task #tag @today t30m end'; const result = service.removeShortSyntaxFromInput(input, 'tags'); expect(result).toBe('Task @today t30m end'); }); - it('should trim final result', () => { + it('should trim final result', async () => { const input = ' #tag Task #another '; const result = service.removeShortSyntaxFromInput(input, 'tags'); expect(result).toBe('Task'); }); - it('should handle mixed removal scenarios', () => { + it('should handle mixed removal scenarios', async () => { // Remove all tags and clean up spacing const input = 'Task #urgent #important content #todo'; const result = service.removeShortSyntaxFromInput(input, 'tags'); expect(result).toBe('Task content'); }); - it('should handle empty input after removal', () => { + it('should handle empty input after removal', async () => { const input = '#tag1 #tag2'; const result = service.removeShortSyntaxFromInput(input, 'tags'); expect(result).toBe(''); }); - it('should handle whitespace-only input after removal', () => { + it('should handle whitespace-only input after removal', async () => { const input = ' #tag1 #tag2 '; const result = service.removeShortSyntaxFromInput(input, 'tags'); expect(result).toBe(''); @@ -578,7 +578,7 @@ describe('AddTaskBarParserService', () => { ]; }); - it('should handle date strings consistently across timezones', () => { + it('should handle date strings consistently across timezones', async () => { const dateStr = '2025-01-15'; const timeStr = '14:30'; @@ -598,7 +598,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task with date', mockConfig, mockProjects, @@ -612,7 +612,7 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateDate).toHaveBeenCalledWith(dateStr, timeStr); }); - it('should handle dates near DST transitions', () => { + it('should handle dates near DST transitions', async () => { // Test spring forward (March DST transition in many timezones) const springDateStr = '2024-03-10'; const springTimeStr = '02:30'; @@ -632,7 +632,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'DST test task', mockConfig, mockProjects, @@ -648,7 +648,7 @@ describe('AddTaskBarParserService', () => { ); }); - it('should handle dates at year boundaries', () => { + it('should handle dates at year boundaries', async () => { // Test New Year's Eve const newYearDateStr = '2024-12-31'; const newYearTimeStr = '23:59'; @@ -668,7 +668,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'New Year task', mockConfig, mockProjects, @@ -684,7 +684,7 @@ describe('AddTaskBarParserService', () => { ); }); - it('should maintain date consistency when parsing date-only strings', () => { + it('should maintain date consistency when parsing date-only strings', async () => { const dateStr = '2025-01-01'; const mockState = { @@ -702,7 +702,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task', mockConfig, mockProjects, @@ -716,7 +716,7 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateDate).toHaveBeenCalledWith(dateStr, null); }); - it('should handle midnight time correctly', () => { + it('should handle midnight time correctly', async () => { const dateStr = '2025-01-15'; const midnightTime = '00:00'; @@ -735,7 +735,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Midnight task', mockConfig, mockProjects, @@ -758,13 +758,13 @@ describe('AddTaskBarParserService', () => { }); describe('_arraysEqual', () => { - it('should return true for equal arrays', () => { + it('should return true for equal arrays', async () => { expect(serviceAny._arraysEqual(['a', 'b'], ['a', 'b'])).toBe(true); expect(serviceAny._arraysEqual([], [])).toBe(true); expect(serviceAny._arraysEqual([1, 2, 3], [1, 2, 3])).toBe(true); }); - it('should return false for different arrays', () => { + it('should return false for different arrays', async () => { expect(serviceAny._arraysEqual(['a', 'b'], ['a', 'c'])).toBe(false); expect(serviceAny._arraysEqual(['a'], ['a', 'b'])).toBe(false); expect(serviceAny._arraysEqual([1, 2], [2, 1])).toBe(false); @@ -772,7 +772,7 @@ describe('AddTaskBarParserService', () => { expect(serviceAny._arraysEqual([], [1])).toBe(false); }); - it('should handle null and undefined values', () => { + it('should handle null and undefined values', async () => { expect(serviceAny._arraysEqual([null], [null])).toBe(true); expect(serviceAny._arraysEqual([undefined], [undefined])).toBe(true); expect(serviceAny._arraysEqual([null], [undefined])).toBe(false); @@ -780,14 +780,14 @@ describe('AddTaskBarParserService', () => { }); describe('_datesEqual', () => { - it('should return true for equal date strings', () => { + it('should return true for equal date strings', async () => { const dateStr1 = '2024-01-15'; const dateStr2 = '2024-01-15'; expect(serviceAny._datesEqual(dateStr1, dateStr2)).toBe(true); expect(serviceAny._datesEqual(null, null)).toBe(true); }); - it('should return false for different date strings', () => { + it('should return false for different date strings', async () => { const dateStr1 = '2024-01-15'; const dateStr2 = '2024-01-16'; expect(serviceAny._datesEqual(dateStr1, dateStr2)).toBe(false); @@ -795,13 +795,13 @@ describe('AddTaskBarParserService', () => { expect(serviceAny._datesEqual(null, dateStr1)).toBe(false); }); - it('should handle same date strings', () => { + it('should handle same date strings', async () => { const dateStr1 = '2024-01-15'; const dateStr2 = '2024-01-15'; expect(serviceAny._datesEqual(dateStr1, dateStr2)).toBe(true); }); - it('should handle different date strings', () => { + it('should handle different date strings', async () => { const dateStr1 = '2024-01-15'; const dateStr2 = '2024-01-16'; expect(serviceAny._datesEqual(dateStr1, dateStr2)).toBe(false); @@ -835,7 +835,7 @@ describe('AddTaskBarParserService', () => { mockStateService.updateAttachments.calls.reset(); }); - it('should extract single HTTPS URL and update state', () => { + it('should extract single HTTPS URL and update state', async () => { const mockState = { projectId: 'default-project', tagIds: [], @@ -851,7 +851,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task https://example.com', mockConfig, mockProjects, @@ -868,7 +868,7 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateCleanText).toHaveBeenCalledWith('Task'); }); - it('should extract file:// URL with FILE type', () => { + it('should extract file:// URL with FILE type', async () => { const mockState = { projectId: 'default-project', tagIds: [], @@ -884,7 +884,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Document file:///home/user/doc.pdf', mockConfig, mockProjects, @@ -901,7 +901,7 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateCleanText).toHaveBeenCalledWith('Document'); }); - it('should detect image URLs as IMG type', () => { + it('should detect image URLs as IMG type', async () => { const mockState = { projectId: 'default-project', tagIds: [], @@ -917,7 +917,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Screenshot https://example.com/image.png', mockConfig, mockProjects, @@ -932,7 +932,7 @@ describe('AddTaskBarParserService', () => { expect(attachments[0].icon).toBe('image'); }); - it('should extract multiple URLs', () => { + it('should extract multiple URLs', async () => { const mockState = { projectId: 'default-project', tagIds: [], @@ -948,7 +948,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Links https://example.com www.test.org', mockConfig, mockProjects, @@ -964,7 +964,7 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateCleanText).toHaveBeenCalledWith('Links'); }); - it('should work with combined short syntax (URL + date + tag + estimate)', () => { + it('should work with combined short syntax (URL + date + tag + estimate)', async () => { const mockState = { projectId: 'default-project', tagIds: [], @@ -983,7 +983,7 @@ describe('AddTaskBarParserService', () => { const mockTag = { id: 'urgent-id', title: 'urgent' } as Tag; const tagsWithUrgent = [mockTag]; - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task https://github.com/pr/123 @tomorrow #urgent 30m', mockConfig, mockProjects, @@ -1006,7 +1006,7 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateTagIdsFromTxt).toHaveBeenCalledWith(['urgent-id']); }); - it('should not extract URLs from empty text', () => { + it('should not extract URLs from empty text', async () => { const mockState = { projectId: 'default-project', tagIds: [], @@ -1022,7 +1022,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( '', mockConfig, mockProjects, @@ -1033,7 +1033,7 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateAttachments).not.toHaveBeenCalled(); }); - it('should update attachments when URL changes', () => { + it('should update attachments when URL changes', async () => { const mockState = { projectId: 'default-project', tagIds: [], @@ -1050,7 +1050,7 @@ describe('AddTaskBarParserService', () => { mockStateService.state.and.returnValue(mockState); // First parse - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task https://example.com', mockConfig, mockProjects, @@ -1065,7 +1065,7 @@ describe('AddTaskBarParserService', () => { expect(firstAttachments[0].path).toBe('https://example.com'); // Change URL - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task https://different.com', mockConfig, mockProjects, @@ -1080,7 +1080,7 @@ describe('AddTaskBarParserService', () => { expect(secondAttachments[0].path).toBe('https://different.com'); }); - it('should clear attachments when URL removed from text', () => { + it('should clear attachments when URL removed from text', async () => { const mockState = { projectId: 'default-project', tagIds: [], @@ -1097,7 +1097,7 @@ describe('AddTaskBarParserService', () => { mockStateService.state.and.returnValue(mockState); // First parse with URL - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task https://example.com', mockConfig, mockProjects, @@ -1108,7 +1108,7 @@ describe('AddTaskBarParserService', () => { expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1); // Remove URL from text - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task', mockConfig, mockProjects, @@ -1121,7 +1121,7 @@ describe('AddTaskBarParserService', () => { expect(attachments.length).toBe(0); }); - it('should handle www URLs correctly', () => { + it('should handle www URLs correctly', async () => { const mockState = { projectId: 'default-project', tagIds: [], @@ -1137,7 +1137,7 @@ describe('AddTaskBarParserService', () => { }; mockStateService.state.and.returnValue(mockState); - service.parseAndUpdateText( + await service.parseAndUpdateText( 'Task www.example.com', mockConfig, mockProjects, diff --git a/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.ts b/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.ts index 0d42d7e7bf..6c24d57a09 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar-parser.service.ts @@ -35,7 +35,7 @@ export class AddTaskBarParserService { return a === b; } - parseAndUpdateText( + async parseAndUpdateText( text: string, config: ShortSyntaxConfig | null, allProjects: Project[], @@ -43,7 +43,7 @@ export class AddTaskBarParserService { defaultProject: Project, defaultDate?: string, defaultTime?: string, - ): void { + ): Promise { if (!text || !config) { this._previousParseResult = null; return; @@ -51,7 +51,7 @@ export class AddTaskBarParserService { // Get current tags from state to preserve pre-selected tags const currentState = this._stateService.state(); - const parseResult = shortSyntax( + const parseResult = await shortSyntax( { title: text, tagIds: currentState.tagIdsFromTxt }, config, allTags, diff --git a/src/app/features/tasks/add-task-bar/add-task-bar.component.ts b/src/app/features/tasks/add-task-bar/add-task-bar.component.ts index bedc1078bc..fe8d033c47 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar.component.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar.component.ts @@ -40,11 +40,12 @@ import { first, map, startWith, + switchMap, timeout, withLatestFrom, } from 'rxjs/operators'; import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view'; -import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, from, Observable } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component'; import { @@ -228,14 +229,16 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy { this._workContextService.activeWorkContext$, this._globalConfigService.shortSyntax$, ), - map(([val, tags, projects, activeWorkContext, shortSyntaxConfig]) => - shortSyntaxToTags({ - val, - tags, - projects, - defaultColor: activeWorkContext?.theme?.primary || DEFAULT_PROJECT_COLOR, - shortSyntaxConfig, - }), + switchMap(([val, tags, projects, activeWorkContext, shortSyntaxConfig]) => + from( + shortSyntaxToTags({ + val, + tags, + projects, + defaultColor: activeWorkContext?.theme?.primary || DEFAULT_PROJECT_COLOR, + shortSyntaxConfig, + }), + ), ), startWith([]), ); @@ -354,21 +357,26 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy { this.defaultProject$, this.defaultDateAndTime$, ]) - .pipe(takeUntilDestroyed(this._destroyRef)) - .subscribe( - ([title, config, allTags, allProjects, defaultProject, defaultDateInfo]) => { - const { date, time } = defaultDateInfo; - this._parserService.parseAndUpdateText( - title || '', - config, - allProjects, - allTags, - defaultProject!, - date, - time, - ); - }, - ); + .pipe( + switchMap( + ([title, config, allTags, allProjects, defaultProject, defaultDateInfo]) => { + const { date, time } = defaultDateInfo; + return from( + this._parserService.parseAndUpdateText( + title || '', + config, + allProjects, + allTags, + defaultProject!, + date, + time, + ), + ); + }, + ), + takeUntilDestroyed(this._destroyRef), + ) + .subscribe(); } private _setupSuggestions(): void { diff --git a/src/app/features/tasks/add-task-bar/short-syntax-to-tags.ts b/src/app/features/tasks/add-task-bar/short-syntax-to-tags.ts index eed7b5e4d9..e748862e3f 100644 --- a/src/app/features/tasks/add-task-bar/short-syntax-to-tags.ts +++ b/src/app/features/tasks/add-task-bar/short-syntax-to-tags.ts @@ -19,7 +19,7 @@ export interface ShortSyntaxTag { projectId?: string; } -export const shortSyntaxToTags = ({ +export const shortSyntaxToTags = async ({ val, tags, projects, @@ -31,8 +31,8 @@ export const shortSyntaxToTags = ({ projects: Project[]; defaultColor: string; shortSyntaxConfig: ShortSyntaxConfig; -}): ShortSyntaxTag[] => { - const r = shortSyntax( +}): Promise => { + const r = await shortSyntax( { title: val, tagIds: [], diff --git a/src/app/features/tasks/short-syntax.spec.ts b/src/app/features/tasks/short-syntax.spec.ts index a53499c56b..e1dbb87e47 100644 --- a/src/app/features/tasks/short-syntax.spec.ts +++ b/src/app/features/tasks/short-syntax.spec.ts @@ -49,11 +49,11 @@ const ALL_TAGS: Tag[] = [ ]; const CONFIG = DEFAULT_GLOBAL_CONFIG.shortSyntax; -const getPlannedDateTimestampFromShortSyntaxReturnValue = ( +const getPlannedDateTimestampFromShortSyntaxReturnValue = async ( taskInput: TaskCopy, now: Date = new Date(), -): number => { - const r = shortSyntax(taskInput, CONFIG, undefined, undefined, now); +): Promise => { + const r = await shortSyntax(taskInput, CONFIG, undefined, undefined, now); const parsedDateInMilliseconds = r?.taskChanges?.dueWithTime as number; return parsedDateInMilliseconds; }; @@ -131,13 +131,13 @@ const checkIfCorrectDateMonthAndYear = ( }; describe('shortSyntax', () => { - it('should ignore for no short syntax', () => { - const r = shortSyntax(TASK, CONFIG); + it('should ignore for no short syntax', async () => { + const r = await shortSyntax(TASK, CONFIG); expect(r).toEqual(undefined); }); - it('should ignore if the changes cause no further changes', () => { - const r = shortSyntax( + it('should ignore if the changes cause no further changes', async () => { + const r = await shortSyntax( { ...TASK, title: 'So what shall I do', @@ -148,12 +148,12 @@ describe('shortSyntax', () => { }); describe('should work for time short syntax', () => { - it('', () => { + it('', async () => { const t = { ...TASK, title: 'Fun title 10m/1h', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -170,12 +170,12 @@ describe('shortSyntax', () => { }); }); - it('', () => { + it('', async () => { const t = { ...TASK, title: 'Fun title whatever 1h/120m', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -192,12 +192,12 @@ describe('shortSyntax', () => { }); }); - it('', () => { + it('', async () => { const t = { ...TASK, title: 'Fun title whatever 1.5h', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -210,12 +210,12 @@ describe('shortSyntax', () => { }); }); - it('', () => { + it('', async () => { const t = { ...TASK, title: 'Fun title whatever 1.5h/2.5h', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -232,21 +232,21 @@ describe('shortSyntax', () => { }); }); - it('should ignore time short syntax when disabled', () => { + it('should ignore time short syntax when disabled', async () => { const t = { ...TASK, title: 'Fun title whatever 1h/120m', }; - const r = shortSyntax(t, { ...CONFIG, isEnableDue: false }); + const r = await shortSyntax(t, { ...CONFIG, isEnableDue: false }); expect(r).toEqual(undefined); }); - it('with time spent only', () => { + it('with time spent only', async () => { const t = { ...TASK, title: 'Task description 30m/', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -263,7 +263,7 @@ describe('shortSyntax', () => { }); describe('should recognize short syntax for date', () => { - it('should correctly parse schedule syntax with time only', () => { + it('should correctly parse schedule syntax with time only', async () => { const t = { ...TASK, title: 'Test @4pm', @@ -271,10 +271,8 @@ describe('shortSyntax', () => { // Use fixed date at 10am to avoid race conditions with real system time // and ensure we're safely before 4pm for same-day scheduling const now = new Date(2024, 0, 15, 10, 0, 0, 0); // Jan 15, 2024 at 10:00 AM - const parsedDateInMilliseconds = getPlannedDateTimestampFromShortSyntaxReturnValue( - t, - now, - ); + const parsedDateInMilliseconds = + await getPlannedDateTimestampFromShortSyntaxReturnValue(t, now); const parsedDate = new Date(parsedDateInMilliseconds); const isSetToSameDay = checkSameDay(parsedDate, now); expect(isSetToSameDay).toBeTrue(); @@ -282,34 +280,32 @@ describe('shortSyntax', () => { expect(isTimeSetCorrectly).toBeTrue(); }); - it('should ignore schedule syntax with time only when disabled', () => { + it('should ignore schedule syntax with time only when disabled', async () => { const t = { ...TASK, title: 'Test @4pm', }; - const r = shortSyntax(t, { ...CONFIG, isEnableDue: false }); + const r = await shortSyntax(t, { ...CONFIG, isEnableDue: false }); expect(r).toEqual(undefined); }); - it('should ignore day of the week when disabled', () => { + it('should ignore day of the week when disabled', async () => { const t = { ...TASK, title: 'Test @Friday', }; - const r = shortSyntax(t, { ...CONFIG, isEnableDue: false }); + const r = await shortSyntax(t, { ...CONFIG, isEnableDue: false }); expect(r).toEqual(undefined); }); - it('should correctly parse day of the week', () => { + it('should correctly parse day of the week', async () => { const t = { ...TASK, title: 'Test @Friday', }; const now = new Date('Fri Feb 09 2024 11:31:29 '); - const parsedDateInMilliseconds = getPlannedDateTimestampFromShortSyntaxReturnValue( - t, - now, - ); + const parsedDateInMilliseconds = + await getPlannedDateTimestampFromShortSyntaxReturnValue(t, now); const parsedDate = new Date(parsedDateInMilliseconds); expect(parsedDate.getDay()).toEqual(5); const dayIncrement = 0; @@ -324,36 +320,36 @@ describe('shortSyntax', () => { expect(isDateSetCorrectly).toBeTrue(); }); - it('should properly remove date syntax when there is a space after @', () => { + it('should properly remove date syntax when there is a space after @', async () => { const t = { ...TASK, title: 'Test @ tomorrow 19:00', }; // set a fixed date to avoid test flakiness const now = new Date('2025-12-05T10:00:00'); - const r = shortSyntax(t, CONFIG, undefined, undefined, now); + const r = await shortSyntax(t, CONFIG, undefined, undefined, now); expect(r?.taskChanges.title).toBe('Test'); expect(r?.taskChanges.dueDay).toBeNull(); }); - it('should properly remove date syntax when there is a space after @ for simple number', () => { + it('should properly remove date syntax when there is a space after @ for simple number', async () => { const t = { ...TASK, title: 'Test @ 4', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r?.taskChanges.title).toBe('Test'); expect(r?.taskChanges.dueDay).toBeNull(); }); - it('should properly remove date syntax with date format like 12/20/25', () => { + it('should properly remove date syntax with date format like 12/20/25', async () => { const t = { ...TASK, title: 'Test @ 12/20/25 19:00', }; const now = new Date('2025-12-05T10:00:00'); - const r = shortSyntax(t, CONFIG, undefined, undefined, now); + const r = await shortSyntax(t, CONFIG, undefined, undefined, now); expect(r?.taskChanges.title).toBe('Test'); expect(r?.taskChanges.dueDay).toBeNull(); @@ -365,14 +361,14 @@ describe('shortSyntax', () => { expect(scheduledDate.getHours()).toBe(19); }); - it('should schedule overdue task to future when using inline @ syntax', () => { + it('should schedule overdue task to future when using inline @ syntax', async () => { const t = { ...TASK, title: 'Overdue task @tomorrow 14:00', dueDay: '2025-12-01', // Simulating an overdue task }; const now = new Date('2025-12-05T10:00:00'); - const r = shortSyntax(t, CONFIG, undefined, undefined, now); + const r = await shortSyntax(t, CONFIG, undefined, undefined, now); expect(r?.taskChanges.title).toBe('Overdue task'); expect(r?.taskChanges.dueDay).toBeNull(); // Should clear the old dueDay @@ -387,42 +383,42 @@ describe('shortSyntax', () => { }); describe('tags', () => { - it('should not trigger for tasks with starting # (e.g. github issues)', () => { + it('should not trigger for tasks with starting # (e.g. github issues)', async () => { const t = { ...TASK, title: '#134 Fun title', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should not trigger for tasks with starting # (e.g. github issues) when disabled', () => { + it('should not trigger for tasks with starting # (e.g. github issues) when disabled', async () => { const t = { ...TASK, title: '#134 Fun title', }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should not parse numeric tag when it is the first word in the title', () => { + it('should not parse numeric tag when it is the first word in the title', async () => { const t = { ...TASK, title: '#123 Task description', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should not trigger for tasks with starting # (e.g. github issues) when adding tags', () => { + it('should not trigger for tasks with starting # (e.g. github issues) when adding tags', async () => { const t = { ...TASK, title: '#134 Fun title #blu', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], @@ -436,22 +432,22 @@ describe('shortSyntax', () => { }); }); - it('should not trigger for multiple tasks when disabled', () => { + it('should not trigger for multiple tasks when disabled', async () => { const t = { ...TASK, title: '#134 Fun title #blu', }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should add tag when it is the first word in the title', () => { + it('should add tag when it is the first word in the title', async () => { const t = { ...TASK, title: '#blu Fun title', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], @@ -465,12 +461,12 @@ describe('shortSyntax', () => { }); }); - it('should add multiple tags even if the first tag is at the beginning', () => { + it('should add multiple tags even if the first tag is at the beginning', async () => { const t = { ...TASK, title: '#blu #hihi Fun title', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], @@ -484,12 +480,12 @@ describe('shortSyntax', () => { }); }); - it('should work with tags', () => { + it('should work with tags', async () => { const t = { ...TASK, title: 'Fun title #blu #A', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], @@ -503,32 +499,32 @@ describe('shortSyntax', () => { }); }); - it("shouldn't work with tags when disabled", () => { + it("shouldn't work with tags when disabled", async () => { const t = { ...TASK, title: 'Fun title #blu #A', }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should not trigger for # without space before', () => { + it('should not trigger for # without space before', async () => { const t = { ...TASK, title: 'Fun title#blu', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should not trigger for # without space before but parse other tags', () => { + it('should not trigger for # without space before but parse other tags', async () => { const t = { ...TASK, title: 'Fun title#blu #bla', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], @@ -542,13 +538,13 @@ describe('shortSyntax', () => { }); }); - it('should not overwrite existing tags', () => { + it('should not overwrite existing tags', async () => { const t = { ...TASK, title: 'Fun title #blu #hihi', tagIds: ['blu_id', 'A', 'multi_word_id'], }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], @@ -562,24 +558,24 @@ describe('shortSyntax', () => { }); }); - it('should not overwrite existing tags when disabled', () => { + it('should not overwrite existing tags when disabled', async () => { const t = { ...TASK, title: 'Fun title #blu #hihi', tagIds: ['blu_id', 'A', 'multi_word_id'], }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should add new tag names', () => { + it('should add new tag names', async () => { const t = { ...TASK, title: 'Fun title #blu #idontexist', tagIds: [], }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: ['idontexist'], @@ -593,24 +589,24 @@ describe('shortSyntax', () => { }); }); - it('should not add new tag names when disabled', () => { + it('should not add new tag names when disabled', async () => { const t = { ...TASK, title: 'Fun title #blu #idontexist', tagIds: [], }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should remove tags not existing on title', () => { + it('should remove tags not existing on title', async () => { const t = { ...TASK, title: 'Fun title #blu #bla', tagIds: ['blu_id', 'bla_id', 'hihi_id'], }; - const r = shortSyntax(t, CONFIG, ALL_TAGS, undefined, undefined, 'replace'); + const r = await shortSyntax(t, CONFIG, ALL_TAGS, undefined, undefined, 'replace'); expect(r).toEqual({ newTagTitles: [], @@ -624,24 +620,24 @@ describe('shortSyntax', () => { }); }); - it('should not remove tags not existing on title when disabled', () => { + it('should not remove tags not existing on title when disabled', async () => { const t = { ...TASK, title: 'Fun title #blu #bla', tagIds: ['blu_id', 'bla_id', 'hihi_id'], }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should add new "asd #asd" tag', () => { + it('should add new "asd #asd" tag', async () => { const t = { ...TASK, title: 'asd #asd', tagIds: [], }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: ['asd'], @@ -654,13 +650,13 @@ describe('shortSyntax', () => { }); }); - it('should work for edge case #3728', () => { + it('should work for edge case #3728', async () => { const t = { ...TASK, title: 'Test tag error #testing #someNewTag3', tagIds: [], }; - const r = shortSyntax(t, CONFIG, [ + const r = await shortSyntax(t, CONFIG, [ ...ALL_TAGS, { ...DEFAULT_TAG, id: 'testing_id', title: 'testing' }, ]); @@ -677,25 +673,25 @@ describe('shortSyntax', () => { }); }); - it('should not add new "asd #asd" tag when disabled', () => { + it('should not add new "asd #asd" tag when disabled', async () => { const t = { ...TASK, title: 'asd #asd', tagIds: [], }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should add tags for sub tasks', () => { + it('should add tags for sub tasks', async () => { const t = { ...TASK, parentId: 'SOMEPARENT', title: 'Fun title #blu #idontexist', tagIds: [], }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: ['idontexist'], @@ -706,25 +702,25 @@ describe('shortSyntax', () => { }); }); - it('should not add tags for sub tasks when disabled', () => { + it('should not add tags for sub tasks when disabled', async () => { const t = { ...TASK, parentId: 'SOMEPARENT', title: 'Fun title #blu #idontexist', tagIds: [], }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual(undefined); }); - it('should remove tag from title if task already has tag', () => { + it('should remove tag from title if task already has tag', async () => { const t = { ...TASK, title: 'Test tag #testing', tagIds: ['testing_id'], }; - const r = shortSyntax(t, CONFIG, [ + const r = await shortSyntax(t, CONFIG, [ ...ALL_TAGS, { ...DEFAULT_TAG, id: 'testing_id', title: 'testing' }, ]); @@ -740,13 +736,13 @@ describe('shortSyntax', () => { }); }); - it('should create new tag and remove both from title if task already has one given tag', () => { + it('should create new tag and remove both from title if task already has one given tag', async () => { const t = { ...TASK, title: 'Test tag #testing #blu', tagIds: ['blu_id'], }; - const r = shortSyntax(t, CONFIG, [ + const r = await shortSyntax(t, CONFIG, [ ...ALL_TAGS, { ...DEFAULT_TAG, id: 'blu_id', title: 'blu' }, ]); @@ -762,13 +758,13 @@ describe('shortSyntax', () => { }); }); - it('should add existing tag and remove both from title if task already has one given tag', () => { + it('should add existing tag and remove both from title if task already has one given tag', async () => { const t = { ...TASK, title: 'Test tag #testing #blu', tagIds: ['blu_id'], }; - const r = shortSyntax(t, CONFIG, [ + const r = await shortSyntax(t, CONFIG, [ ...ALL_TAGS, { ...DEFAULT_TAG, id: 'blu_id', title: 'blu' }, { ...DEFAULT_TAG, id: 'testing_id', title: 'testing' }, @@ -786,24 +782,24 @@ describe('shortSyntax', () => { }); }); - it('should not remove tag from title if task already has tag when disabled', () => { + it('should not remove tag from title if task already has tag when disabled', async () => { const t = { ...TASK, title: 'Test tag #testing', tagIds: ['testing_id'], }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual(undefined); }); }); describe('should work with tags and time estimates combined', () => { - it('tag before time estimate', () => { + it('tag before time estimate', async () => { const t = { ...TASK, title: 'Fun title #blu 10m/1h', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -821,12 +817,12 @@ describe('shortSyntax', () => { }); }); - it('time estimate before tag', () => { + it('time estimate before tag', async () => { const t = { ...TASK, title: 'Fun title 10m/1h #blu', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -843,12 +839,12 @@ describe('shortSyntax', () => { }); }); - it('time estimate disabled', () => { + it('time estimate disabled', async () => { const t = { ...TASK, title: 'Fun title 10m/1h #blu', }; - const r = shortSyntax(t, { ...CONFIG, isEnableDue: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableDue: false }, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -861,12 +857,12 @@ describe('shortSyntax', () => { }); }); - it('tags disabled', () => { + it('tags disabled', async () => { const t = { ...TASK, title: 'Fun title 10m/1h #blu', }; - const r = shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); + const r = await shortSyntax(t, { ...CONFIG, isEnableTag: false }, ALL_TAGS); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -898,12 +894,12 @@ describe('shortSyntax', () => { ] as any; }); - it('should work', () => { + it('should work', async () => { const t = { ...TASK, title: 'Fun title +ProjectEasyShort', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -915,30 +911,30 @@ describe('shortSyntax', () => { }); }); - it("shouldn't work when disabled", () => { + it("shouldn't work when disabled", async () => { const t = { ...TASK, title: 'Fun title +ProjectEasyShort', }; - const r = shortSyntax(t, { ...CONFIG, isEnableProject: false }, [], projects); + const r = await shortSyntax(t, { ...CONFIG, isEnableProject: false }, [], projects); expect(r).toEqual(undefined); }); - it('should not parse without missing whitespace before', () => { + it('should not parse without missing whitespace before', async () => { const t = { ...TASK, title: 'Fun title+ProjectEasyShort', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual(undefined); }); - it('should work together with time estimates', () => { + it('should work together with time estimates', async () => { const t = { ...TASK, title: 'Fun title +ProjectEasyShort 10m/1h', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -955,12 +951,12 @@ describe('shortSyntax', () => { }); }); - it('should work together with time estimates when disabled', () => { + it('should work together with time estimates when disabled', async () => { const t = { ...TASK, title: 'Fun title +ProjectEasyShort 10m/1h', }; - const r = shortSyntax(t, { ...CONFIG, isEnableProject: false }, [], projects); + const r = await shortSyntax(t, { ...CONFIG, isEnableProject: false }, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -977,12 +973,12 @@ describe('shortSyntax', () => { }); }); - it('should work together with disabled time estimates', () => { + it('should work together with disabled time estimates', async () => { const t = { ...TASK, title: 'Fun title +ProjectEasyShort 10m/1h', }; - const r = shortSyntax(t, { ...CONFIG, isEnableDue: false }, [], projects); + const r = await shortSyntax(t, { ...CONFIG, isEnableDue: false }, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -994,12 +990,12 @@ describe('shortSyntax', () => { }); }); - it('should work with only the beginning of a project title if it is at least 3 chars long', () => { + it('should work with only the beginning of a project title if it is at least 3 chars long', async () => { const t = { ...TASK, title: 'Fun title +Project', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -1011,12 +1007,12 @@ describe('shortSyntax', () => { }); }); - it('should work with multi word project titles', () => { + it('should work with multi word project titles', async () => { const t = { ...TASK, title: 'Fun title +Some Project Title', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -1028,12 +1024,12 @@ describe('shortSyntax', () => { }); }); - it('should work with multi word project titles partial', () => { + it('should work with multi word project titles partial', async () => { const t = { ...TASK, title: 'Fun title +Some Pro', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -1045,12 +1041,12 @@ describe('shortSyntax', () => { }); }); - it('should work with multi word project titles partial written without white space', () => { + it('should work with multi word project titles partial written without white space', async () => { const t = { ...TASK, title: 'Other fun title +SomePro', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -1062,16 +1058,16 @@ describe('shortSyntax', () => { }); }); - it('should ignore non existing', () => { + it('should ignore non existing', async () => { const t = { ...TASK, title: 'Other fun title +Non existing project', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual(undefined); }); - it('should prefer shortest prefix full project title match', () => { + it('should prefer shortest prefix full project title match', async () => { const t = { ...TASK, title: 'Task +print', @@ -1079,7 +1075,7 @@ describe('shortSyntax', () => { projects = ['printer', 'imprints', 'print', 'printable'].map( (title) => ({ id: title, title }) as Project, ); - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -1093,7 +1089,7 @@ describe('shortSyntax', () => { }); describe('combined', () => { - it('should work when time comes first', () => { + it('should work when time comes first', async () => { const projects = [ { title: 'ProjectEasyShort', @@ -1104,7 +1100,7 @@ describe('shortSyntax', () => { ...TASK, title: 'Fun title 10m/1h +ProjectEasyShort', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual({ newTagTitles: [], remindAt: null, @@ -1121,7 +1117,7 @@ describe('shortSyntax', () => { }); }); - it('should work for project first', () => { + it('should work for project first', async () => { const projects = [ { title: 'ProjectEasyShort', @@ -1132,7 +1128,7 @@ describe('shortSyntax', () => { ...TASK, title: 'Some task +ProjectEasyShort 30m #tag', }; - const r = shortSyntax(t, CONFIG, [], projects); + const r = await shortSyntax(t, CONFIG, [], projects); expect(r).toEqual({ newTagTitles: ['tag'], remindAt: null, @@ -1145,7 +1141,7 @@ describe('shortSyntax', () => { }, }); }); - it('should correctly parse scheduled date, project, time spent and estimate', () => { + it('should correctly parse scheduled date, project, time spent and estimate', async () => { const projects = [ { title: 'Project', @@ -1162,13 +1158,13 @@ describe('shortSyntax', () => { title: taskInput, }; const parsedDateInMilliseconds = - getPlannedDateTimestampFromShortSyntaxReturnValue(t); + await getPlannedDateTimestampFromShortSyntaxReturnValue(t); const parsedDate = new Date(parsedDateInMilliseconds); // The parsed day and time should be Friday, or 5, and time is 16 hours and 0 minute expect(parsedDate.getDay()).toEqual(5); const isTimeSetCorrectly = checkIfDateHasCorrectTime(parsedDate, 16, 0); expect(isTimeSetCorrectly).toBeTrue(); - const parsedTaskInfo = shortSyntax(t, CONFIG, [], projects); + const parsedTaskInfo = await shortSyntax(t, CONFIG, [], projects); expect(parsedTaskInfo?.projectId).toEqual(projects[0].id); // The time spent value is stored to the property equal to today // in format YYYY-MM-DD of the object `timeSpentOnDay` @@ -1180,12 +1176,12 @@ describe('shortSyntax', () => { 3600 * 2 * 1000, ); }); - it('should correctly parse scheduled date and multiple tags', () => { + it('should correctly parse scheduled date and multiple tags', async () => { const t = { ...TASK, title: 'Test @fri 4pm #html #css', }; - const plannedTimestamp = getPlannedDateTimestampFromShortSyntaxReturnValue(t); + const plannedTimestamp = await getPlannedDateTimestampFromShortSyntaxReturnValue(t); const isPlannedDateAndTimeCorrect = checkIfCorrectDateAndTime( plannedTimestamp, 'friday', @@ -1193,26 +1189,26 @@ describe('shortSyntax', () => { 0, ); expect(isPlannedDateAndTimeCorrect).toBeTrue(); - const parsedTaskInfo = shortSyntax(t, CONFIG, []); + const parsedTaskInfo = await shortSyntax(t, CONFIG, []); expect(parsedTaskInfo?.newTagTitles.includes('html')).toBeTrue(); expect(parsedTaskInfo?.newTagTitles.includes('css')).toBeTrue(); }); - it('should parse scheduled date using local time zone when unspecified', () => { + it('should parse scheduled date using local time zone when unspecified', async () => { const t = { ...TASK, title: '@2030-10-12T13:37', }; - const plannedTimestamp = getPlannedDateTimestampFromShortSyntaxReturnValue(t); + const plannedTimestamp = await getPlannedDateTimestampFromShortSyntaxReturnValue(t); expect(checkIfCorrectDateAndTime(plannedTimestamp, 'saturday', 13, 37)).toBeTrue(); }); - it('should work when all are disabled', () => { + it('should work when all are disabled', async () => { const t = { ...TASK, title: 'Test @fri 4pm #html #css +ProjectEasyShort', }; - const r = shortSyntax(t, { + const r = await shortSyntax(t, { isEnableDue: false, isEnableProject: false, isEnableTag: false, @@ -1238,12 +1234,12 @@ describe('shortSyntax', () => { for (const taskTemplate of taskTemplates) { for (const project of projects) { const taskTitle = taskTemplate.replaceAll('*', `+${project.title}`); - it(`should parse project "${project.title}" from "${taskTitle}"`, () => { + it(`should parse project "${project.title}" from "${taskTitle}"`, async () => { const task = { ...TASK, title: taskTitle, }; - const result = shortSyntax(task, CONFIG, ALL_TAGS, projects); + const result = await shortSyntax(task, CONFIG, ALL_TAGS, projects); expect(result?.projectId).toBe(project.id); }); } @@ -1258,7 +1254,7 @@ describe('shortSyntax', () => { const today = new Date(); const minuteEstimate = 90; - it('should correctly parse year and time estimate when the input date only has month and day of the month', () => { + it('should correctly parse year and time estimate when the input date only has month and day of the month', async () => { const tomorrow = new Date(today.getTime() + oneDayInMilliseconds); const inputMonth = tomorrow.getMonth() + 1; const inputMonthName = MONTH_SHORT_NAMES[tomorrow.getMonth()]; @@ -1267,7 +1263,7 @@ describe('shortSyntax', () => { ...TASK, title: `Test @${inputMonthName} ${inputDayOfTheMonth} ${minuteEstimate}m`, }; - const parsedTaskInfo = shortSyntax(t, CONFIG, []); + const parsedTaskInfo = await shortSyntax(t, CONFIG, []); const taskChanges = parsedTaskInfo?.taskChanges; const dueWithTime = taskChanges?.dueWithTime as number; expect( @@ -1281,7 +1277,7 @@ describe('shortSyntax', () => { expect(taskChanges?.timeEstimate).toEqual(minuteEstimate * 60 * 1000); }); - it('should correctly parse year and time estimate when the input date contains month, day of the month and time', () => { + it('should correctly parse year and time estimate when the input date contains month, day of the month and time', async () => { const time = '4pm'; const tomorrow = new Date(today.getTime() + oneDayInMilliseconds); const inputMonth = tomorrow.getMonth() + 1; @@ -1291,7 +1287,7 @@ describe('shortSyntax', () => { ...TASK, title: `Test @${inputMonthName} ${inputDayOfTheMonth} ${time} ${minuteEstimate}m`, }; - const parsedTaskInfo = shortSyntax(t, CONFIG, []); + const parsedTaskInfo = await shortSyntax(t, CONFIG, []); const taskChanges = parsedTaskInfo?.taskChanges; const dueWithTime = taskChanges?.dueWithTime as number; expect( @@ -1331,12 +1327,12 @@ describe('shortSyntax', () => { timeSpentOnDay === undefined ? 'no time spent on day' : 'time spent on day of ' + timeSpentOnDay - } from "${title}"`, () => { + } from "${title}"`, async () => { const task = { ...TASK, title, }; - const result = shortSyntax(task, CONFIG, [], []); + const result = await shortSyntax(task, CONFIG, [], []); expect(result?.taskChanges.timeEstimate).toBe(timeEstimate); expect(result?.taskChanges.timeSpentOnDay?.[getDbDateStr()]).toBe(timeSpentOnDay); }); @@ -1344,12 +1340,12 @@ describe('shortSyntax', () => { }); describe('URL attachments', () => { - it('should extract single URL with https protocol', () => { + it('should extract single URL with https protocol', async () => { const t = { ...TASK, title: 'Task https://example.com', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments).toBeDefined(); expect(r?.attachments.length).toBe(1); @@ -1359,12 +1355,12 @@ describe('shortSyntax', () => { expect(r?.taskChanges.title).toBe('Task'); }); - it('should extract single URL with http protocol', () => { + it('should extract single URL with http protocol', async () => { const t = { ...TASK, title: 'Task http://example.com', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].path).toBe('http://example.com'); @@ -1372,12 +1368,12 @@ describe('shortSyntax', () => { expect(r?.taskChanges.title).toBe('Task'); }); - it('should extract single URL with file:// protocol', () => { + it('should extract single URL with file:// protocol', async () => { const t = { ...TASK, title: 'Task file:///path/to/document.pdf', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].path).toBe('file:///path/to/document.pdf'); @@ -1386,12 +1382,12 @@ describe('shortSyntax', () => { expect(r?.taskChanges.title).toBe('Task'); }); - it('should extract single URL with www prefix', () => { + it('should extract single URL with www prefix', async () => { const t = { ...TASK, title: 'Task www.example.com', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].path).toBe('//www.example.com'); @@ -1399,12 +1395,12 @@ describe('shortSyntax', () => { expect(r?.taskChanges.title).toBe('Task'); }); - it('should handle multiple URLs with mixed protocols', () => { + it('should handle multiple URLs with mixed protocols', async () => { const t = { ...TASK, title: 'Task https://example.com www.test.org file:///home/doc.txt', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(3); expect(r?.attachments[0].path).toBe('https://example.com'); @@ -1416,12 +1412,12 @@ describe('shortSyntax', () => { expect(r?.taskChanges.title).toBe('Task'); }); - it('should detect image URLs as IMG type for https', () => { + it('should detect image URLs as IMG type for https', async () => { const t = { ...TASK, title: 'Task https://example.com/image.png', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].type).toBe('IMG'); @@ -1429,12 +1425,12 @@ describe('shortSyntax', () => { expect(r?.taskChanges.title).toBe('Task'); }); - it('should detect image URLs as IMG type for file://', () => { + it('should detect image URLs as IMG type for file://', async () => { const t = { ...TASK, title: 'Task file:///path/to/image.jpg', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].type).toBe('IMG'); @@ -1442,12 +1438,12 @@ describe('shortSyntax', () => { expect(r?.taskChanges.title).toBe('Task'); }); - it('should work correctly with combined short syntax', () => { + it('should work correctly with combined short syntax', async () => { const t = { ...TASK, title: 'Task https://example.com @tomorrow #urgent 30m', }; - const r = shortSyntax(t, CONFIG, ALL_TAGS); + const r = await shortSyntax(t, CONFIG, ALL_TAGS); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].path).toBe('https://example.com'); @@ -1457,23 +1453,23 @@ describe('shortSyntax', () => { expect(r?.taskChanges.dueWithTime).toBeDefined(); }); - it('should clean URLs from title properly', () => { + it('should clean URLs from title properly', async () => { const t = { ...TASK, title: 'Task with https://example.com in middle', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.taskChanges.title).toBe('Task with in middle'); expect(r?.attachments.length).toBe(1); }); - it('should handle Windows file paths', () => { + it('should handle Windows file paths', async () => { const t = { ...TASK, title: 'Task file:///C:/Users/name/document.pdf', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].path).toBe('file:///C:/Users/name/document.pdf'); @@ -1481,12 +1477,12 @@ describe('shortSyntax', () => { expect(r?.taskChanges.title).toBe('Task'); }); - it('should handle Unix file paths', () => { + it('should handle Unix file paths', async () => { const t = { ...TASK, title: 'Task file:///home/user/document.txt', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].path).toBe('file:///home/user/document.txt'); @@ -1494,45 +1490,45 @@ describe('shortSyntax', () => { expect(r?.taskChanges.title).toBe('Task'); }); - it('should not parse URLs for issue tasks', () => { + it('should not parse URLs for issue tasks', async () => { const t = { ...TASK, title: 'Task https://example.com', issueId: 'ISSUE-123', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeUndefined(); }); - it('should handle URLs with trailing punctuation', () => { + it('should handle URLs with trailing punctuation', async () => { const t = { ...TASK, title: 'Check https://example.com.', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].path).toBe('https://example.com'); expect(r?.taskChanges.title).toBe('Check .'); }); - it('should extract basename as attachment title', () => { + it('should extract basename as attachment title', async () => { const t = { ...TASK, title: 'Task https://example.com/path/to/file.pdf', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].title).toBe('file'); }); - it('should extract basename correctly for URLs with trailing slash', () => { + it('should extract basename correctly for URLs with trailing slash', async () => { const t = { ...TASK, title: 'Task https://example.com/projects/', }; - const r = shortSyntax(t, CONFIG); + const r = await shortSyntax(t, CONFIG); expect(r).toBeDefined(); expect(r?.attachments.length).toBe(1); expect(r?.attachments[0].path).toBe('https://example.com/projects/'); diff --git a/src/app/features/tasks/short-syntax.ts b/src/app/features/tasks/short-syntax.ts index c67c24c6eb..18d084640b 100644 --- a/src/app/features/tasks/short-syntax.ts +++ b/src/app/features/tasks/short-syntax.ts @@ -1,4 +1,3 @@ -import { casual } from 'chrono-node'; import { Task, TaskCopy } from './task.model'; import { getDbDateStr } from '../../util/get-db-date-str'; import { stringToMs } from '../../ui/duration/string-to-ms.pipe'; @@ -37,44 +36,53 @@ const CH_TAG = '#'; const CH_DUE = '@'; const ALL_SPECIAL = `(\\${CH_PRO}|\\${CH_TAG}|\\${CH_DUE})`; -const customDateParser = casual.clone(); -customDateParser.refiners.push({ - refine: (context, results) => { - results.forEach((result) => { - const { refDate, text, start } = result; - const regex = / [5-9][0-9]$/; - const yearIndex = text.search(regex); - // The year pattern in Chrono's source code is (?:[1-9][0-9]{0,3}\\s{0,2}(?:BE|AD|BC|BCE|CE)|[1-2][0-9]{3}|[5-9][0-9]|2[0-5]). - // This means any two-digit numeric value from 50 to 99 will be considered a year. - // Link: https://github.com/wanasit/chrono/blob/54e7ff12f9185e735ee860c25922b2ab2367d40b/src/locales/en/constants.ts#L234C30-L234C108 - // When someone creates a task like "Test @25/4 90m", Chrono will return the year as 1990, which is an undesirable behaviour in most cases. - if (yearIndex !== -1) { - result.text = text.slice(0, yearIndex); - const current = new Date(); - let year = current.getFullYear(); - // If the parsed month is smaller than the current month, - // it means the time is for next year. For example, parsed month is March - // and it is currently April - const impliedDate = start.get('day'); - const impliedMonth = start.get('month'); - // Due to the future-forward nature of the date parser, there are two scenarios that the implied year is next year: - // - Implied month is smaller than current month i.e. 20/3 vs 2/4 - // - Same month but the implied date is before the current date i.e. 14/4 vs 20/4 - if ( - (impliedMonth && impliedMonth < refDate.getMonth() + 1) || - (impliedMonth === refDate.getMonth() + 1 && - impliedDate && - impliedDate < refDate.getDate()) - ) { - // || (impliedMonth === refDate.getMonth() + 1 && impliedDay && impliedDay < refDate.getDay()) - year += 1; - } - result.start.assign('year', year); - } +let customDateParserPromise: Promise | null = null; +let customDateParserCache: any = null; + +const loadCustomDateParser = (): Promise => { + if (customDateParserCache) { + return Promise.resolve(customDateParserCache); + } + if (!customDateParserPromise) { + customDateParserPromise = import('chrono-node').then(({ casual }) => { + const parser = casual.clone(); + parser.refiners.push({ + refine: (context: unknown, results: any[]) => { + results.forEach((result) => { + const { refDate, text, start } = result; + const regex = / [5-9][0-9]$/; + const yearIndex = text.search(regex); + // The year pattern in Chrono's source code is (?:[1-9][0-9]{0,3}\\s{0,2}(?:BE|AD|BC|BCE|CE)|[1-2][0-9]{3}|[5-9][0-9]|2[0-5]). + // This means any two-digit numeric value from 50 to 99 will be considered a year. + // Link: https://github.com/wanasit/chrono/blob/54e7ff12f9185e735ee860c25922b2ab2367d40b/src/locales/en/constants.ts#L234C30-L234C108 + // When someone creates a task like "Test @25/4 90m", Chrono will return + // the year as 1990, which is an undesirable behaviour in most cases. + if (yearIndex !== -1) { + result.text = text.slice(0, yearIndex); + const current = new Date(); + let year = current.getFullYear(); + const impliedDate = start.get('day'); + const impliedMonth = start.get('month'); + if ( + (impliedMonth && impliedMonth < refDate.getMonth() + 1) || + (impliedMonth === refDate.getMonth() + 1 && + impliedDate && + impliedDate < refDate.getDate()) + ) { + year += 1; + } + result.start.assign('year', year); + } + }); + return results; + }, + }); + customDateParserCache = parser; + return parser; }); - return results; - }, -}); + } + return customDateParserPromise; +}; // The following project name extraction pattern attempts to improve on the // previous version by not immediately terminating upon encountering a short @@ -97,14 +105,14 @@ const SHORT_SYNTAX_URL_REG_EX = new RegExp( 'gi', ); -export const shortSyntax = ( +export const shortSyntax = async ( task: Task | Partial, config: ShortSyntaxConfig, allTags?: Tag[], allProjects?: Project[], now = new Date(), mode: 'combine' | 'replace' = 'combine', -): +): Promise< | { taskChanges: Partial; newTagTitles: string[]; @@ -112,7 +120,8 @@ export const shortSyntax = ( projectId: string | undefined; attachments: TaskAttachment[]; } - | undefined => { + | undefined +> => { if (!task.title) { return; } @@ -130,7 +139,10 @@ export const shortSyntax = ( taskChanges = parseTimeSpentChanges(task); taskChanges = { ...taskChanges, - ...parseScheduledDate({ ...task, title: taskChanges.title || task.title }, now), + ...(await parseScheduledDate( + { ...task, title: taskChanges.title || task.title }, + now, + )), }; } @@ -365,14 +377,18 @@ const parseTagChanges = ( }; }; -const parseScheduledDate = (task: Partial, now: Date): DueChanges => { +const parseScheduledDate = async ( + task: Partial, + now: Date, +): Promise => { if (!task.title) { return {}; } const rr = task.title.match(SHORT_SYNTAX_DUE_REG_EX); if (rr && rr[0]) { - const parsedDateArr = customDateParser.parse(rr[0], now, { + const dateParser = await loadCustomDateParser(); + const parsedDateArr = dateParser.parse(rr[0], now, { forwardDate: true, }); diff --git a/src/app/features/tasks/store/short-syntax.effects.ts b/src/app/features/tasks/store/short-syntax.effects.ts index f74ea31ec4..a5aabf462c 100644 --- a/src/app/features/tasks/store/short-syntax.effects.ts +++ b/src/app/features/tasks/store/short-syntax.effects.ts @@ -17,7 +17,7 @@ import { import { GlobalConfigService } from '../../config/global-config.service'; import { unique } from '../../../util/unique'; import { TaskService } from '../task.service'; -import { EMPTY, of } from 'rxjs'; +import { from, of } from 'rxjs'; import { ProjectService } from '../../project/project.service'; import { TagService } from '../../tag/tag.service'; import { shortSyntax } from '../short-syntax'; @@ -103,120 +103,126 @@ export class ShortSyntaxEffects { ), mergeMap(([{ task, originalAction }, tags, projects, defaultProjectId]) => { const isReplaceTagIds = originalAction.type === TaskSharedActions.updateTask.type; - const r = shortSyntax( - task, - this._globalConfigService?.cfg()?.shortSyntax || - DEFAULT_GLOBAL_CONFIG.shortSyntax, - tags, - projects, - undefined, - isReplaceTagIds ? 'replace' : 'combine', - ); - if (environment.production) { - TaskLog.log('shortSyntax', r); - } - const isAddDefaultProjectIfNecessary: boolean = - !!defaultProjectId && - !task.projectId && - !task.parentId && - task.projectId !== defaultProjectId && - originalAction.type === TaskSharedActions.addTask.type; - - if (!r) { - if (isAddDefaultProjectIfNecessary) { - return [ - TaskSharedActions.moveToOtherProject({ - task: { ...task, subTasks: [] }, - targetProjectId: defaultProjectId as string, - }), - ]; - } - return EMPTY; - } - - const actions: Action[] = []; - const { taskChanges, attachments } = r; - - // Build scheduling info from parsed short syntax - let schedulingInfo: - | { - day?: string; - isAddToTop?: boolean; - dueWithTime?: number; - remindAt?: number | null; - isMoveToBacklog?: boolean; - } - | undefined; - - if (taskChanges.dueWithTime && !taskChanges.remindAt) { - const { dueWithTime } = taskChanges; - if (taskChanges.hasPlannedTime === false) { - // Plan for day only (no specific time) - const plannedDay = new Date(dueWithTime); - schedulingInfo = { - day: getDbDateStr(plannedDay), - isAddToTop: false, - }; - } else { - // Schedule with specific time - schedulingInfo = { - dueWithTime, - remindAt: remindOptionToMilliseconds( - dueWithTime, - this._globalConfigService.cfg()?.reminder.defaultTaskRemindOption ?? - DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!, - ), - isMoveToBacklog: false, - }; - } - } - - // Determine target project - let targetProjectId: string | undefined; - if (r.projectId && r.projectId !== task.projectId && !task.parentId) { - if (task.repeatCfgId) { - this._snackService.open({ - ico: 'warning', - msg: T.F.TASK.S.CANNOT_ASSIGN_PROJECT_FOR_REPEATABLE_TASK, - }); - } else { - targetProjectId = r.projectId; - } - } else if (isAddDefaultProjectIfNecessary) { - targetProjectId = defaultProjectId as string; - } - - // Build task changes including tagIds update - const tagIds: string[] = [...(r.taskChanges.tagIds || task.tagIds)]; - const isEqualTags = JSON.stringify(tagIds) === JSON.stringify(task.tagIds); - const finalTaskChanges = { ...taskChanges }; - if (tagIds && tagIds.length && !isEqualTags) { - finalTaskChanges.tagIds = unique(tagIds); - } - - // Add parsed URL attachments to task (merge with existing) - if (attachments.length > 0) { - finalTaskChanges.attachments = [...(task.attachments || []), ...attachments]; - } - - // Use compound action for atomic state update - actions.push( - TaskSharedActions.applyShortSyntax({ + return from( + shortSyntax( task, - taskChanges: finalTaskChanges, - targetProjectId, - schedulingInfo, + this._globalConfigService?.cfg()?.shortSyntax || + DEFAULT_GLOBAL_CONFIG.shortSyntax, + tags, + projects, + undefined, + isReplaceTagIds ? 'replace' : 'combine', + ).then((r) => { + if (environment.production) { + TaskLog.log('shortSyntax', r); + } + const isAddDefaultProjectIfNecessary: boolean = + !!defaultProjectId && + !task.projectId && + !task.parentId && + task.projectId !== defaultProjectId && + originalAction.type === TaskSharedActions.addTask.type; + + if (!r) { + if (isAddDefaultProjectIfNecessary) { + return [ + TaskSharedActions.moveToOtherProject({ + task: { ...task, subTasks: [] }, + targetProjectId: defaultProjectId as string, + }), + ]; + } + return [] as Action[]; + } + + const actions: Action[] = []; + const { taskChanges, attachments } = r; + + // Build scheduling info from parsed short syntax + let schedulingInfo: + | { + day?: string; + isAddToTop?: boolean; + dueWithTime?: number; + remindAt?: number | null; + isMoveToBacklog?: boolean; + } + | undefined; + + if (taskChanges.dueWithTime && !taskChanges.remindAt) { + const { dueWithTime } = taskChanges; + if (taskChanges.hasPlannedTime === false) { + // Plan for day only (no specific time) + const plannedDay = new Date(dueWithTime); + schedulingInfo = { + day: getDbDateStr(plannedDay), + isAddToTop: false, + }; + } else { + // Schedule with specific time + schedulingInfo = { + dueWithTime, + remindAt: remindOptionToMilliseconds( + dueWithTime, + this._globalConfigService.cfg()?.reminder.defaultTaskRemindOption ?? + DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!, + ), + isMoveToBacklog: false, + }; + } + } + + // Determine target project + let targetProjectId: string | undefined; + if (r.projectId && r.projectId !== task.projectId && !task.parentId) { + if (task.repeatCfgId) { + this._snackService.open({ + ico: 'warning', + msg: T.F.TASK.S.CANNOT_ASSIGN_PROJECT_FOR_REPEATABLE_TASK, + }); + } else { + targetProjectId = r.projectId; + } + } else if (isAddDefaultProjectIfNecessary) { + targetProjectId = defaultProjectId as string; + } + + // Build task changes including tagIds update + const tagIds: string[] = [...(r.taskChanges.tagIds || task.tagIds)]; + const isEqualTags = JSON.stringify(tagIds) === JSON.stringify(task.tagIds); + const finalTaskChanges = { ...taskChanges }; + if (tagIds && tagIds.length && !isEqualTags) { + finalTaskChanges.tagIds = unique(tagIds); + } + + // Add parsed URL attachments to task (merge with existing) + if (attachments.length > 0) { + finalTaskChanges.attachments = [ + ...(task.attachments || []), + ...attachments, + ]; + } + + // Use compound action for atomic state update + actions.push( + TaskSharedActions.applyShortSyntax({ + task, + taskChanges: finalTaskChanges, + targetProjectId, + schedulingInfo, + }), + ); + + // New tag creation requires user confirmation, so remains separate + if (r.newTagTitles.length) { + actions.push( + addNewTagsFromShortSyntax({ taskId: task.id, newTitles: r.newTagTitles }), + ); + } + + return actions; }), - ); - - // New tag creation requires user confirmation, so remains separate - if (r.newTagTitles.length) { - actions.push( - addNewTagsFromShortSyntax({ taskId: task.id, newTitles: r.newTagTitles }), - ); - } - - return actions; + ).pipe(mergeMap((actions) => actions)); }), ), );