mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
f784c9c0b9
commit
522ebb39a7
10 changed files with 1002 additions and 2 deletions
|
|
@ -60,6 +60,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
isEnableProject: true,
|
||||
isEnableDue: true,
|
||||
isEnableTag: true,
|
||||
isEnableUrl: true,
|
||||
},
|
||||
evaluation: {
|
||||
isHideEvaluationSheet: false,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export type ShortSyntaxConfig = Readonly<{
|
|||
isEnableProject: boolean;
|
||||
isEnableDue: boolean;
|
||||
isEnableTag: boolean;
|
||||
isEnableUrl: boolean;
|
||||
}>;
|
||||
|
||||
export type TimeTrackingConfig = Readonly<{
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue