feat(tasks): add URL attachment support in task short syntax

Users can now add URL attachments when creating tasks using short syntax.
URLs are automatically detected and extracted as attachments, with the URL
removed from the task title.

Supported URL types:
- https:// and http:// URLs → LINK type
- file:// URLs → FILE type for local documents
- www. URLs → LINK type (auto-adds // protocol)
- Image URLs (.png, .jpg, .gif, .jpeg) → IMG type

Example usage:
  "Review PR https://github.com/org/repo/pull/123 @tomorrow #urgent t30m"
  Creates task with title "Review PR", URL attachment, date, tag, and estimate

Features:
- Configurable via isEnableUrl setting (defaults to true)
- Works alongside existing short syntax (@date, #tag, +project, t30m)
- Handles multiple URLs in one task
- Properly syncs via operation log

Tests added:
- 14 unit tests for URL parsing logic
- 11 integration tests for parser service
- 10 integration tests for state service
- All 232 tests passing

Closes #6067
This commit is contained in:
Johannes Millan 2026-01-20 14:04:16 +01:00
parent f784c9c0b9
commit 522ebb39a7
10 changed files with 1002 additions and 2 deletions

View file

@ -60,6 +60,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
isEnableProject: true,
isEnableDue: true,
isEnableTag: true,
isEnableUrl: true,
},
evaluation: {
isHideEvaluationSheet: false,

View file

@ -60,6 +60,7 @@ export type ShortSyntaxConfig = Readonly<{
isEnableProject: boolean;
isEnableDue: boolean;
isEnableTag: boolean;
isEnableUrl: boolean;
}>;
export type TimeTrackingConfig = Readonly<{

View file

@ -19,6 +19,7 @@ describe('AddTaskBarParserService', () => {
'updateSpent',
'updateEstimate',
'updateDate',
'updateAttachments',
'isAutoDetected',
'state',
]);
@ -95,6 +96,7 @@ describe('AddTaskBarParserService', () => {
mockStateService.updateNewTagTitles.calls.reset();
mockStateService.setAutoDetectedProjectId.calls.reset();
mockStateService.updateProjectId.calls.reset();
mockStateService.updateAttachments.calls.reset();
});
it('should handle empty text', () => {
@ -124,6 +126,7 @@ describe('AddTaskBarParserService', () => {
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
@ -160,6 +163,7 @@ describe('AddTaskBarParserService', () => {
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
@ -193,6 +197,7 @@ describe('AddTaskBarParserService', () => {
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
@ -595,6 +600,7 @@ describe('AddTaskBarParserService', () => {
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
@ -628,6 +634,7 @@ describe('AddTaskBarParserService', () => {
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
@ -663,6 +670,7 @@ describe('AddTaskBarParserService', () => {
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
@ -696,6 +704,7 @@ describe('AddTaskBarParserService', () => {
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
@ -728,6 +737,7 @@ describe('AddTaskBarParserService', () => {
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
@ -804,4 +814,388 @@ describe('AddTaskBarParserService', () => {
});
});
});
describe('URL Attachment Integration', () => {
let mockConfig: ShortSyntaxConfig;
let mockProjects: Project[];
let mockTags: Tag[];
let mockDefaultProject: Project;
beforeEach(() => {
mockConfig = {
isEnableProject: true,
isEnableDue: true,
isEnableTag: true,
isEnableUrl: true,
} as ShortSyntaxConfig;
mockDefaultProject = {
id: 'default-project',
title: 'Default Project',
} as Project;
mockProjects = [mockDefaultProject];
mockTags = [];
// Reset all spy calls
mockStateService.updateCleanText.calls.reset();
mockStateService.updateAttachments.calls.reset();
});
it('should extract single HTTPS URL and update state', () => {
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
service.parseAndUpdateText(
'Task https://example.com',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1);
const attachments = mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(attachments.length).toBe(1);
expect(attachments[0].path).toBe('https://example.com');
expect(attachments[0].type).toBe('LINK');
expect(attachments[0].icon).toBe('bookmark');
expect(mockStateService.updateCleanText).toHaveBeenCalledWith('Task');
});
it('should extract file:// URL with FILE type', () => {
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
service.parseAndUpdateText(
'Document file:///home/user/doc.pdf',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1);
const attachments = mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(attachments.length).toBe(1);
expect(attachments[0].path).toBe('file:///home/user/doc.pdf');
expect(attachments[0].type).toBe('FILE');
expect(attachments[0].icon).toBe('insert_drive_file');
expect(mockStateService.updateCleanText).toHaveBeenCalledWith('Document');
});
it('should detect image URLs as IMG type', () => {
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
service.parseAndUpdateText(
'Screenshot https://example.com/image.png',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1);
const attachments = mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(attachments.length).toBe(1);
expect(attachments[0].type).toBe('IMG');
expect(attachments[0].icon).toBe('image');
});
it('should extract multiple URLs', () => {
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
service.parseAndUpdateText(
'Links https://example.com www.test.org',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1);
const attachments = mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(attachments.length).toBe(2);
expect(attachments[0].path).toBe('https://example.com');
expect(attachments[1].path).toBe('//www.test.org');
expect(mockStateService.updateCleanText).toHaveBeenCalledWith('Links');
});
it('should work with combined short syntax (URL + date + tag + estimate)', () => {
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
const mockTag = { id: 'urgent-id', title: 'urgent' } as Tag;
const tagsWithUrgent = [mockTag];
service.parseAndUpdateText(
'Task https://github.com/pr/123 @tomorrow #urgent 30m',
mockConfig,
mockProjects,
tagsWithUrgent,
mockDefaultProject,
);
// Should extract URL
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1);
const attachments = mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(attachments.length).toBe(1);
expect(attachments[0].path).toBe('https://github.com/pr/123');
// Should clean title
expect(mockStateService.updateCleanText).toHaveBeenCalledWith('Task');
// Should parse other syntax
expect(mockStateService.updateDate).toHaveBeenCalled();
expect(mockStateService.updateEstimate).toHaveBeenCalledWith(1800000); // 30m in ms
expect(mockStateService.updateTagIdsFromTxt).toHaveBeenCalledWith(['urgent-id']);
});
it('should not extract URLs when config disabled', () => {
const disabledConfig = {
...mockConfig,
isEnableUrl: false,
};
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
service.parseAndUpdateText(
'Task https://example.com',
disabledConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
// Should call updateAttachments with empty array (no URLs extracted)
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1);
const attachments = mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(attachments.length).toBe(0);
// Title should not be cleaned
expect(mockStateService.updateCleanText).toHaveBeenCalledWith(
'Task https://example.com',
);
});
it('should not extract URLs from empty text', () => {
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
service.parseAndUpdateText(
'',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).not.toHaveBeenCalled();
});
it('should update attachments when URL changes', () => {
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
// First parse
service.parseAndUpdateText(
'Task https://example.com',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1);
const firstAttachments =
mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(firstAttachments.length).toBe(1);
expect(firstAttachments[0].path).toBe('https://example.com');
// Change URL
service.parseAndUpdateText(
'Task https://different.com',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(2);
const secondAttachments =
mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(secondAttachments.length).toBe(1);
expect(secondAttachments[0].path).toBe('https://different.com');
});
it('should clear attachments when URL removed from text', () => {
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
// First parse with URL
service.parseAndUpdateText(
'Task https://example.com',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1);
// Remove URL from text
service.parseAndUpdateText(
'Task',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(2);
const attachments = mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(attachments.length).toBe(0);
});
it('should handle www URLs correctly', () => {
const mockState = {
projectId: 'default-project',
tagIds: [],
tagIdsFromTxt: [],
newTagTitles: [],
date: null,
time: null,
spent: null,
estimate: null,
cleanText: null,
remindOption: null,
attachments: [],
};
mockStateService.state.and.returnValue(mockState);
service.parseAndUpdateText(
'Task www.example.com',
mockConfig,
mockProjects,
mockTags,
mockDefaultProject,
);
expect(mockStateService.updateAttachments).toHaveBeenCalledTimes(1);
const attachments = mockStateService.updateAttachments.calls.mostRecent().args[0];
expect(attachments.length).toBe(1);
expect(attachments[0].path).toBe('//www.example.com');
expect(attachments[0].type).toBe('LINK');
});
});
});

View file

@ -6,6 +6,7 @@ import { SHORT_SYNTAX_TIME_REG_EX, shortSyntax } from '../short-syntax';
import { ShortSyntaxConfig } from '../../config/global-config.model';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { TimeSpentOnDay } from '../task.model';
import { TaskAttachment } from '../task-attachment/task-attachment.model';
interface PreviousParseResult {
cleanText: string | null;
@ -16,6 +17,7 @@ interface PreviousParseResult {
timeEstimate: number | null;
dueDate: string | null;
dueTime: string | null;
attachments: TaskAttachment[];
}
@Injectable()
@ -77,6 +79,7 @@ export class AddTaskBarParserService {
// Preserve current date/time if user has selected them, otherwise use defaults
dueDate: currentState.date || (defaultDate ? defaultDate : null),
dueTime: currentState.time || defaultTime || null,
attachments: [],
};
} else {
// Extract parsed values
@ -113,6 +116,7 @@ export class AddTaskBarParserService {
timeEstimate: parseResult.taskChanges.timeEstimate || null,
dueDate: dueDate,
dueTime: dueTime,
attachments: parseResult.attachments || [],
};
}
@ -192,6 +196,13 @@ export class AddTaskBarParserService {
this._stateService.updateDate(currentResult.dueDate, currentResult.dueTime);
}
if (
!this._previousParseResult ||
!this._arraysEqual(this._previousParseResult.attachments, currentResult.attachments)
) {
this._stateService.updateAttachments(currentResult.attachments);
}
// Store current result as previous for next comparison
this._previousParseResult = currentResult;
}
@ -202,7 +213,7 @@ export class AddTaskBarParserService {
removeShortSyntaxFromInput(
currentInput: string,
type: 'tags' | 'date' | 'estimate',
type: 'tags' | 'date' | 'estimate' | 'urls',
specificTag?: string,
): string {
if (!currentInput) return currentInput;
@ -233,6 +244,14 @@ export class AddTaskBarParserService {
' ',
);
break;
case 'urls':
// Remove URL syntax (e.g., https://example.com www.example.com file:///path)
cleanedInput = cleanedInput.replace(
/(?:(?:https?|file):\/\/\S+|www\.\S+?)(?=\s|$)/gi,
'',
);
break;
}
// Clean up extra whitespace

View file

@ -413,4 +413,220 @@ describe('AddTaskBarStateService', () => {
expect(service.state().projectId).toEqual(mockProject.id);
});
});
describe('updateAttachments', () => {
it('should update attachments in state', () => {
const mockAttachments = [
{
id: 'att-1',
type: 'LINK' as const,
path: 'https://example.com',
title: 'Example',
icon: 'bookmark',
},
{
id: 'att-2',
type: 'FILE' as const,
path: 'file:///path/to/doc.pdf',
title: 'Document',
icon: 'insert_drive_file',
},
];
service.updateAttachments(mockAttachments);
expect(service.state().attachments).toEqual(mockAttachments);
});
it('should replace existing attachments', () => {
const firstAttachments = [
{
id: 'att-1',
type: 'LINK' as const,
path: 'https://first.com',
title: 'First',
icon: 'bookmark',
},
];
const secondAttachments = [
{
id: 'att-2',
type: 'LINK' as const,
path: 'https://second.com',
title: 'Second',
icon: 'bookmark',
},
];
service.updateAttachments(firstAttachments);
expect(service.state().attachments).toEqual(firstAttachments);
service.updateAttachments(secondAttachments);
expect(service.state().attachments).toEqual(secondAttachments);
});
it('should handle empty attachments array', () => {
service.updateAttachments([]);
expect(service.state().attachments).toEqual([]);
});
it('should handle image type attachments', () => {
const imageAttachment = [
{
id: 'img-1',
type: 'IMG' as const,
path: 'https://example.com/image.png',
title: 'Image',
icon: 'image',
},
];
service.updateAttachments(imageAttachment);
expect(service.state().attachments[0].type).toBe('IMG');
expect(service.state().attachments[0].icon).toBe('image');
});
it('should not affect other state properties', () => {
const initialState = service.state();
const mockAttachments = [
{
id: 'att-1',
type: 'LINK' as const,
path: 'https://example.com',
title: 'Example',
icon: 'bookmark',
},
];
service.updateAttachments(mockAttachments);
const updatedState = service.state();
expect(updatedState.projectId).toEqual(initialState.projectId);
expect(updatedState.tagIds).toEqual(initialState.tagIds);
expect(updatedState.date).toEqual(initialState.date);
expect(updatedState.estimate).toEqual(initialState.estimate);
});
});
describe('clearAttachments', () => {
it('should clear attachments from state', () => {
const mockAttachments = [
{
id: 'att-1',
type: 'LINK' as const,
path: 'https://example.com',
title: 'Example',
icon: 'bookmark',
},
];
service.updateAttachments(mockAttachments);
expect(service.state().attachments.length).toBe(1);
service.clearAttachments();
expect(service.state().attachments).toEqual([]);
});
it('should update input text if provided', () => {
const mockAttachments = [
{
id: 'att-1',
type: 'LINK' as const,
path: 'https://example.com',
title: 'Example',
icon: 'bookmark',
},
];
service.updateAttachments(mockAttachments);
service.updateInputTxt('Task with URL https://example.com');
service.clearAttachments('Task with URL');
expect(service.state().attachments).toEqual([]);
expect(service.inputTxt()).toBe('Task with URL');
});
it('should not update input text if not provided', () => {
const mockAttachments = [
{
id: 'att-1',
type: 'LINK' as const,
path: 'https://example.com',
title: 'Example',
icon: 'bookmark',
},
];
service.updateAttachments(mockAttachments);
service.updateInputTxt('Original text');
service.clearAttachments();
expect(service.state().attachments).toEqual([]);
expect(service.inputTxt()).toBe('Original text');
});
it('should work when attachments already empty', () => {
expect(service.state().attachments).toEqual([]);
service.clearAttachments();
expect(service.state().attachments).toEqual([]);
});
});
describe('resetAfterAdd', () => {
it('should clear attachments along with other fields', () => {
const mockAttachments = [
{
id: 'att-1',
type: 'LINK' as const,
path: 'https://example.com',
title: 'Example',
icon: 'bookmark',
},
];
service.updateAttachments(mockAttachments);
service.updateInputTxt('Test task');
const mockTag: Tag = { id: 'tag-1', title: 'urgent' } as Tag;
service.toggleTag(mockTag);
expect(service.state().attachments.length).toBe(1);
expect(service.inputTxt()).toBe('Test task');
expect(service.state().tagIds.length).toBe(1);
service.resetAfterAdd();
expect(service.state().attachments).toEqual([]);
expect(service.inputTxt()).toBe('');
expect(service.state().tagIds).toEqual([]);
});
it('should preserve project, date, and estimate when clearing attachments', () => {
const mockAttachments = [
{
id: 'att-1',
type: 'LINK' as const,
path: 'https://example.com',
title: 'Example',
icon: 'bookmark',
},
];
service.updateProjectId('project-1');
service.updateDate('2024-01-15', '14:00');
service.updateEstimate(3600000);
service.updateAttachments(mockAttachments);
service.resetAfterAdd();
expect(service.state().attachments).toEqual([]);
expect(service.state().projectId).toBe('project-1');
expect(service.state().date).toBe('2024-01-15');
expect(service.state().estimate).toBe(3600000);
});
});
});

View file

@ -4,6 +4,7 @@ import { AddTaskBarState, INITIAL_ADD_TASK_BAR_STATE } from './add-task-bar.cons
import { toObservable } from '@angular/core/rxjs-interop';
import { SS } from '../../../core/persistence/storage-keys.const';
import { TimeSpentOnDay, TaskReminderOptionId } from '../task.model';
import { TaskAttachment } from '../task-attachment/task-attachment.model';
@Injectable()
export class AddTaskBarStateService {
@ -113,6 +114,17 @@ export class AddTaskBarStateService {
}
}
updateAttachments(attachments: TaskAttachment[]): void {
this._taskInputState.update((state) => ({ ...state, attachments }));
}
clearAttachments(cleanedInputTxt?: string): void {
this._taskInputState.update((state) => ({ ...state, attachments: [] }));
if (cleanedInputTxt !== undefined) {
this.inputTxt.set(cleanedInputTxt);
}
}
resetAfterAdd(): void {
// Only clear input text and tags, preserve project, date, and estimate
this._taskInputState.update((state) => ({
@ -121,6 +133,7 @@ export class AddTaskBarStateService {
tagIdsFromTxt: [],
newTagTitles: [],
cleanText: null,
attachments: [],
}));
this.inputTxt.set('');
// Keep isAutoDetected as is to preserve project selection

View file

@ -436,6 +436,10 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy {
: finalTagIds,
// needs to be 0
timeEstimate: state.estimate || 0,
attachments:
state.attachments.length > 0
? state.attachments
: additionalFields?.attachments || [],
};
if (state.spent) {

View file

@ -1,5 +1,6 @@
import { INBOX_PROJECT } from '../../project/project.const';
import { TimeSpentOnDay, TaskReminderOptionId } from '../task.model';
import { TaskAttachment } from '../task-attachment/task-attachment.model';
export interface AddTaskBarState {
projectId: string;
@ -12,6 +13,7 @@ export interface AddTaskBarState {
newTagTitles: string[];
cleanText: string | null;
remindOption: TaskReminderOptionId | null;
attachments: TaskAttachment[];
}
export const ESTIMATE_OPTIONS = [
{ label: '5m', value: '5m' },
@ -36,6 +38,7 @@ export const INITIAL_ADD_TASK_BAR_STATE: AddTaskBarState = {
newTagTitles: [],
cleanText: null,
remindOption: null,
attachments: [],
};
export const CHRONO_SUGGESTIONS: string[] = [

View file

@ -163,6 +163,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title',
// timeSpent: 7200000,
@ -184,6 +185,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title whatever',
// timeSpent: 7200000,
@ -205,6 +207,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title whatever',
timeEstimate: 5400000,
@ -222,6 +225,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title whatever',
// timeSpent: 7200000,
@ -252,6 +256,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Task description',
timeSpentOnDay: {
@ -431,6 +436,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: '#134 Fun title',
tagIds: ['blu_id'],
@ -459,6 +465,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title',
tagIds: ['blu_id'],
@ -477,6 +484,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title',
tagIds: ['blu_id', 'hihi_id'],
@ -495,6 +503,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title',
tagIds: ['blu_id', 'A_id'],
@ -533,6 +542,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title#blu',
tagIds: ['bla_id'],
@ -552,6 +562,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title',
tagIds: ['blu_id', 'A', 'multi_word_id', 'hihi_id'],
@ -582,6 +593,7 @@ describe('shortSyntax', () => {
newTagTitles: ['idontexist'],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title',
tagIds: ['blu_id'],
@ -612,6 +624,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title',
tagIds: ['blu_id', 'bla_id'],
@ -642,6 +655,7 @@ describe('shortSyntax', () => {
newTagTitles: ['asd'],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'asd',
},
@ -663,6 +677,7 @@ describe('shortSyntax', () => {
newTagTitles: ['someNewTag3'],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Test tag error',
tagIds: ['testing_id'],
@ -693,6 +708,7 @@ describe('shortSyntax', () => {
expect(r).toEqual({
newTagTitles: ['idontexist'],
projectId: undefined,
attachments: [],
remindAt: null,
taskChanges: { tagIds: ['blu_id'], title: 'Fun title' },
});
@ -725,6 +741,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Test tag',
},
@ -746,6 +763,7 @@ describe('shortSyntax', () => {
newTagTitles: ['testing'],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Test tag',
},
@ -768,6 +786,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Test tag',
tagIds: ['blu_id', 'testing_id'],
@ -797,6 +816,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title',
// timeSpent: 7200000,
@ -819,6 +839,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title',
timeSpentOnDay: {
@ -840,6 +861,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title 10m/1h',
tagIds: ['blu_id'],
@ -857,6 +879,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title #blu',
timeSpentOnDay: {
@ -893,6 +916,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: 'ProjectEasyShortID',
attachments: [],
taskChanges: {
title: 'Fun title',
},
@ -927,6 +951,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: 'ProjectEasyShortID',
attachments: [],
taskChanges: {
title: 'Fun title',
// timeSpent: 7200000,
@ -948,6 +973,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: undefined,
attachments: [],
taskChanges: {
title: 'Fun title +ProjectEasyShort',
// timeSpent: 7200000,
@ -969,6 +995,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: 'ProjectEasyShortID',
attachments: [],
taskChanges: {
title: 'Fun title 10m/1h',
},
@ -985,6 +1012,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: 'ProjectEasyShortID',
attachments: [],
taskChanges: {
title: 'Fun title',
},
@ -1001,6 +1029,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: 'SomeProjectID',
attachments: [],
taskChanges: {
title: 'Fun title',
},
@ -1017,6 +1046,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: 'SomeProjectID',
attachments: [],
taskChanges: {
title: 'Fun title',
},
@ -1033,6 +1063,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: 'SomeProjectID',
attachments: [],
taskChanges: {
title: 'Other fun title',
},
@ -1061,6 +1092,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: 'print',
attachments: [],
taskChanges: {
title: 'Task',
},
@ -1085,6 +1117,7 @@ describe('shortSyntax', () => {
newTagTitles: [],
remindAt: null,
projectId: 'ProjectEasyShortID',
attachments: [],
taskChanges: {
title: 'Fun title',
// timeSpent: 7200000,
@ -1112,6 +1145,7 @@ describe('shortSyntax', () => {
newTagTitles: ['tag'],
remindAt: null,
projectId: 'ProjectEasyShortID',
attachments: [],
taskChanges: {
title: 'Some task',
// timeSpent: 7200000,
@ -1190,6 +1224,7 @@ describe('shortSyntax', () => {
isEnableDue: false,
isEnableProject: false,
isEnableTag: false,
isEnableUrl: false,
});
expect(r).toEqual(undefined);
});
@ -1316,4 +1351,198 @@ describe('shortSyntax', () => {
});
}
});
describe('URL attachments', () => {
it('should extract single URL with https protocol', () => {
const t = {
...TASK,
title: 'Task https://example.com',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].path).toBe('https://example.com');
expect(r?.attachments[0].type).toBe('LINK');
expect(r?.attachments[0].icon).toBe('bookmark');
expect(r?.taskChanges.title).toBe('Task');
});
it('should extract single URL with http protocol', () => {
const t = {
...TASK,
title: 'Task http://example.com',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].path).toBe('http://example.com');
expect(r?.attachments[0].type).toBe('LINK');
expect(r?.taskChanges.title).toBe('Task');
});
it('should extract single URL with file:// protocol', () => {
const t = {
...TASK,
title: 'Task file:///path/to/document.pdf',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].path).toBe('file:///path/to/document.pdf');
expect(r?.attachments[0].type).toBe('FILE');
expect(r?.attachments[0].icon).toBe('insert_drive_file');
expect(r?.taskChanges.title).toBe('Task');
});
it('should extract single URL with www prefix', () => {
const t = {
...TASK,
title: 'Task www.example.com',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].path).toBe('//www.example.com');
expect(r?.attachments[0].type).toBe('LINK');
expect(r?.taskChanges.title).toBe('Task');
});
it('should handle multiple URLs with mixed protocols', () => {
const t = {
...TASK,
title: 'Task https://example.com www.test.org file:///home/doc.txt',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(3);
expect(r?.attachments[0].path).toBe('https://example.com');
expect(r?.attachments[0].type).toBe('LINK');
expect(r?.attachments[1].path).toBe('//www.test.org');
expect(r?.attachments[1].type).toBe('LINK');
expect(r?.attachments[2].path).toBe('file:///home/doc.txt');
expect(r?.attachments[2].type).toBe('FILE');
expect(r?.taskChanges.title).toBe('Task');
});
it('should detect image URLs as IMG type for https', () => {
const t = {
...TASK,
title: 'Task https://example.com/image.png',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].type).toBe('IMG');
expect(r?.attachments[0].icon).toBe('image');
expect(r?.taskChanges.title).toBe('Task');
});
it('should detect image URLs as IMG type for file://', () => {
const t = {
...TASK,
title: 'Task file:///path/to/image.jpg',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].type).toBe('IMG');
expect(r?.attachments[0].icon).toBe('image');
expect(r?.taskChanges.title).toBe('Task');
});
it('should work correctly with combined short syntax', () => {
const t = {
...TASK,
title: 'Task https://example.com @tomorrow #urgent 30m',
};
const r = shortSyntax(t, CONFIG, ALL_TAGS);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].path).toBe('https://example.com');
expect(r?.taskChanges.title).toBe('Task');
expect(r?.taskChanges.timeEstimate).toBe(1800000);
expect(r?.newTagTitles).toContain('urgent');
expect(r?.taskChanges.dueWithTime).toBeDefined();
});
it('should respect config flag and not extract when disabled', () => {
const t = {
...TASK,
title: 'Task https://example.com',
};
const r = shortSyntax(t, { ...CONFIG, isEnableUrl: false });
expect(r).toBeUndefined();
});
it('should clean URLs from title properly', () => {
const t = {
...TASK,
title: 'Task with https://example.com in middle',
};
const r = 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', () => {
const t = {
...TASK,
title: 'Task file:///C:/Users/name/document.pdf',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].path).toBe('file:///C:/Users/name/document.pdf');
expect(r?.attachments[0].type).toBe('FILE');
expect(r?.taskChanges.title).toBe('Task');
});
it('should handle Unix file paths', () => {
const t = {
...TASK,
title: 'Task file:///home/user/document.txt',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].path).toBe('file:///home/user/document.txt');
expect(r?.attachments[0].type).toBe('FILE');
expect(r?.taskChanges.title).toBe('Task');
});
it('should not parse URLs for issue tasks', () => {
const t = {
...TASK,
title: 'Task https://example.com',
issueId: 'ISSUE-123',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeUndefined();
});
it('should handle URLs with trailing punctuation', () => {
const t = {
...TASK,
title: 'Check https://example.com.',
};
const r = 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', () => {
const t = {
...TASK,
title: 'Task https://example.com/path/to/file.pdf',
};
const r = shortSyntax(t, CONFIG);
expect(r).toBeDefined();
expect(r?.attachments.length).toBe(1);
expect(r?.attachments[0].title).toBe('file');
});
});
});

View file

@ -5,6 +5,9 @@ import { stringToMs } from '../../ui/duration/string-to-ms.pipe';
import { Tag } from '../tag/tag.model';
import { Project } from '../project/project.model';
import { ShortSyntaxConfig } from '../config/global-config.model';
import { isImageUrlSimple } from '../../util/is-image-url';
import { TaskAttachment } from './task-attachment/task-attachment.model';
import { nanoid } from 'nanoid';
type ProjectChanges = {
title?: string;
projectId?: string;
@ -87,6 +90,13 @@ const SHORT_SYNTAX_TAGS_REG_EX = new RegExp(`\\${CH_TAG}[^${ALL_SPECIAL}|\\s]+`,
// not in the ALL_SPECIAL
const SHORT_SYNTAX_DUE_REG_EX = new RegExp(`\\${CH_DUE}[^${ALL_SPECIAL}]+`, 'gi');
// Match URLs with protocol (http, https, file) or www prefix
// Matches URLs but excludes trailing punctuation
const SHORT_SYNTAX_URL_REG_EX = new RegExp(
String.raw`(?:(?:https?|file)://\S+|www\.\S+?)(?=\s|$)`,
'gi',
);
export const shortSyntax = (
task: Task | Partial<Task>,
config: ShortSyntaxConfig,
@ -100,6 +110,7 @@ export const shortSyntax = (
newTagTitles: string[];
remindAt: number | null;
projectId: string | undefined;
attachments: TaskAttachment[];
}
| undefined => {
if (!task.title) {
@ -113,6 +124,7 @@ export const shortSyntax = (
let taskChanges: Partial<TaskCopy> = {};
let changesForProject: ProjectChanges = {};
let changesForTag: TagChanges = {};
let attachments: TaskAttachment[] = [];
if (config.isEnableDue) {
taskChanges = parseTimeSpentChanges(task);
@ -147,6 +159,20 @@ export const shortSyntax = (
};
}
if (config.isEnableUrl) {
const urlChanges = parseUrlAttachments({
...task,
title: taskChanges.title || task.title,
});
if (urlChanges.attachments.length > 0) {
attachments = urlChanges.attachments;
taskChanges = {
...taskChanges,
title: urlChanges.title,
};
}
}
// const changesForDue = parseDueChanges({...task, title: taskChanges.title || task.title});
// if (changesForDue.remindAt) {
// taskChanges = {
@ -155,7 +181,7 @@ export const shortSyntax = (
// };
// }
if (Object.keys(taskChanges).length === 0) {
if (Object.keys(taskChanges).length === 0 && attachments.length === 0) {
return undefined;
}
@ -164,6 +190,7 @@ export const shortSyntax = (
newTagTitles: changesForTag.newTagTitlesToCreate || [],
remindAt: null,
projectId: changesForProject.projectId,
attachments,
// remindAt: changesForDue.remindAt
};
};
@ -440,3 +467,96 @@ const parseTimeSpentChanges = (task: Partial<TaskCopy>): Partial<Task> => {
title: task.title.replace(matchSpan, '').trim(),
};
};
const parseUrlAttachments = (
task: Partial<TaskCopy>,
): {
attachments: TaskAttachment[];
title: string;
} => {
if (!task.title || task.issueId) {
return { attachments: [], title: task.title || '' };
}
const urlMatches = task.title.match(SHORT_SYNTAX_URL_REG_EX);
if (!urlMatches || urlMatches.length === 0) {
return { attachments: [], title: task.title };
}
const attachments: TaskAttachment[] = urlMatches.map((url) => {
let path = url.trim();
// Remove trailing punctuation that's not part of the URL
path = path.replace(/[.,;!?]+$/, '');
const isFileProtocol = path.startsWith('file://');
// Add protocol if missing (for www. URLs)
if (!path.match(/^(?:https?|file):\/\//)) {
path = '//' + path;
}
// Detect if it's an image
const isImage = isImageUrlSimple(path);
// Determine type and icon
let type: 'FILE' | 'LINK' | 'IMG';
let icon: string;
if (isImage) {
type = 'IMG';
icon = 'image';
} else if (isFileProtocol) {
type = 'FILE';
icon = 'insert_drive_file';
} else {
type = 'LINK';
icon = 'bookmark';
}
// Extract basename for title
const title = _baseNameForUrl(path);
return {
id: nanoid(),
type,
path,
title,
icon,
};
});
// Clean URLs from title - use trimmed URLs without trailing punctuation
let cleanedTitle = task.title;
attachments.forEach((attachment) => {
const attachmentPath = attachment.path;
if (!attachmentPath) return;
// For www URLs, the path has '//' prepended, but the original doesn't
const originalUrl = attachmentPath.startsWith('//')
? attachmentPath.substring(2)
: attachmentPath;
// Escape special regex characters for safe replacement
const escapedUrl = originalUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
cleanedTitle = cleanedTitle.replace(new RegExp(escapedUrl, 'g'), '');
});
cleanedTitle = cleanedTitle.trim().replace(/\s+/g, ' ');
return { attachments, title: cleanedTitle };
};
const _baseNameForUrl = (passedStr: string): string => {
const str = passedStr.trim();
let base;
if (str[str.length - 1] === '/') {
const strippedStr = str.substring(0, str.length - 2);
base = strippedStr.substring(strippedStr.lastIndexOf('/') + 1);
} else {
base = str.substring(str.lastIndexOf('/') + 1);
}
if (base.lastIndexOf('.') !== -1) {
base = base.substring(0, base.lastIndexOf('.'));
}
return base;
};