@doist/todoist-ai 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/tool-helpers.d.ts +2 -0
- package/dist/tool-helpers.d.ts.map +1 -1
- package/dist/tool-helpers.js +2 -0
- package/dist/tool-helpers.test.js +20 -0
- package/dist/tools/__tests__/tasks-add-multiple.test.js +114 -0
- package/dist/tools/__tests__/tasks-update-one.test.js +90 -0
- package/dist/tools/tasks-add-multiple.d.ts +5 -0
- package/dist/tools/tasks-add-multiple.d.ts.map +1 -1
- package/dist/tools/tasks-add-multiple.js +24 -1
- package/dist/tools/tasks-list-by-date.d.ts +1 -0
- package/dist/tools/tasks-list-by-date.d.ts.map +1 -1
- package/dist/tools/tasks-list-completed.d.ts +1 -0
- package/dist/tools/tasks-list-completed.d.ts.map +1 -1
- package/dist/tools/tasks-list-for-container.d.ts +1 -0
- package/dist/tools/tasks-list-for-container.d.ts.map +1 -1
- package/dist/tools/tasks-search.d.ts +1 -0
- package/dist/tools/tasks-search.d.ts.map +1 -1
- package/dist/tools/tasks-update-one.d.ts +2 -0
- package/dist/tools/tasks-update-one.d.ts.map +1 -1
- package/dist/tools/tasks-update-one.js +24 -1
- package/dist/tools/test-helpers.d.ts +1 -0
- package/dist/tools/test-helpers.d.ts.map +1 -1
- package/dist/tools/test-helpers.js +1 -0
- package/dist/utils/duration-parser.d.ts +36 -0
- package/dist/utils/duration-parser.d.ts.map +1 -0
- package/dist/utils/duration-parser.js +96 -0
- package/dist/utils/duration-parser.test.d.ts +2 -0
- package/dist/utils/duration-parser.test.d.ts.map +1 -0
- package/dist/utils/duration-parser.test.js +147 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -136,6 +136,7 @@ declare const tools: {
|
|
|
136
136
|
sectionId: string | null;
|
|
137
137
|
parentId: string | null;
|
|
138
138
|
labels: string[];
|
|
139
|
+
duration: string | null;
|
|
139
140
|
}[];
|
|
140
141
|
nextCursor: string | null;
|
|
141
142
|
}>;
|
|
@@ -176,6 +177,7 @@ declare const tools: {
|
|
|
176
177
|
sectionId: string | null;
|
|
177
178
|
parentId: string | null;
|
|
178
179
|
labels: string[];
|
|
180
|
+
duration: string | null;
|
|
179
181
|
}[];
|
|
180
182
|
nextCursor: string | null;
|
|
181
183
|
}>;
|
|
@@ -206,6 +208,7 @@ declare const tools: {
|
|
|
206
208
|
sectionId: string | null;
|
|
207
209
|
parentId: string | null;
|
|
208
210
|
labels: string[];
|
|
211
|
+
duration: string | null;
|
|
209
212
|
}[];
|
|
210
213
|
nextCursor: string | null;
|
|
211
214
|
}>;
|
|
@@ -247,6 +250,7 @@ declare const tools: {
|
|
|
247
250
|
sectionId: string | null;
|
|
248
251
|
parentId: string | null;
|
|
249
252
|
labels: string[];
|
|
253
|
+
duration: string | null;
|
|
250
254
|
}[];
|
|
251
255
|
nextCursor: string | null;
|
|
252
256
|
}>;
|
|
@@ -263,16 +267,19 @@ declare const tools: {
|
|
|
263
267
|
description: import("zod").ZodOptional<import("zod").ZodString>;
|
|
264
268
|
priority: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
265
269
|
dueString: import("zod").ZodOptional<import("zod").ZodString>;
|
|
270
|
+
duration: import("zod").ZodOptional<import("zod").ZodString>;
|
|
266
271
|
}, "strip", import("zod").ZodTypeAny, {
|
|
267
272
|
content: string;
|
|
268
273
|
description?: string | undefined;
|
|
269
274
|
priority?: number | undefined;
|
|
270
275
|
dueString?: string | undefined;
|
|
276
|
+
duration?: string | undefined;
|
|
271
277
|
}, {
|
|
272
278
|
content: string;
|
|
273
279
|
description?: string | undefined;
|
|
274
280
|
priority?: number | undefined;
|
|
275
281
|
dueString?: string | undefined;
|
|
282
|
+
duration?: string | undefined;
|
|
276
283
|
}>, "many">;
|
|
277
284
|
};
|
|
278
285
|
execute(args: {
|
|
@@ -281,6 +288,7 @@ declare const tools: {
|
|
|
281
288
|
description?: string | undefined;
|
|
282
289
|
priority?: number | undefined;
|
|
283
290
|
dueString?: string | undefined;
|
|
291
|
+
duration?: string | undefined;
|
|
284
292
|
}[];
|
|
285
293
|
parentId?: string | undefined;
|
|
286
294
|
projectId?: string | undefined;
|
|
@@ -296,6 +304,7 @@ declare const tools: {
|
|
|
296
304
|
sectionId: string | null;
|
|
297
305
|
parentId: string | null;
|
|
298
306
|
labels: string[];
|
|
307
|
+
duration: string | null;
|
|
299
308
|
}[]>;
|
|
300
309
|
};
|
|
301
310
|
tasksUpdateOne: {
|
|
@@ -310,6 +319,7 @@ declare const tools: {
|
|
|
310
319
|
parentId: import("zod").ZodOptional<import("zod").ZodString>;
|
|
311
320
|
priority: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
312
321
|
dueString: import("zod").ZodOptional<import("zod").ZodString>;
|
|
322
|
+
duration: import("zod").ZodOptional<import("zod").ZodString>;
|
|
313
323
|
};
|
|
314
324
|
execute(args: {
|
|
315
325
|
id: string;
|
|
@@ -320,6 +330,7 @@ declare const tools: {
|
|
|
320
330
|
sectionId?: string | undefined;
|
|
321
331
|
priority?: number | undefined;
|
|
322
332
|
dueString?: string | undefined;
|
|
333
|
+
duration?: string | undefined;
|
|
323
334
|
}, client: import("@doist/todoist-api-typescript").TodoistApi): Promise<import("@doist/todoist-api-typescript").Task | undefined>;
|
|
324
335
|
};
|
|
325
336
|
tasksOrganizeMultiple: {
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAE9C,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAE3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAE3D,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAA;AAChE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAA;AAC1E,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAA;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,qCAAqC,CAAA;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAA;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAE5D,QAAA,MAAM,KAAK
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAE9C,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAE3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAE3D,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAA;AAChE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAA;AAC1E,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAA;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,qCAAqC,CAAA;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAA;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAE5D,QAAA,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAeV,CAAA;AAED,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,CAAA;AAE9B,OAAO,EACH,YAAY,EACZ,cAAc,EACd,SAAS,EACT,cAAc,EACd,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,kBAAkB,EAClB,qBAAqB,EACrB,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,qBAAqB,EACrB,QAAQ,GACX,CAAA"}
|
package/dist/tool-helpers.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ declare function mapTask(task: Task): {
|
|
|
28
28
|
sectionId: string | null;
|
|
29
29
|
parentId: string | null;
|
|
30
30
|
labels: string[];
|
|
31
|
+
duration: string | null;
|
|
31
32
|
};
|
|
32
33
|
/**
|
|
33
34
|
* Map a single Todoist project to a more structured format, for LLM consumption.
|
|
@@ -61,6 +62,7 @@ declare function getTasksByFilter({ client, query, limit, cursor, }: {
|
|
|
61
62
|
sectionId: string | null;
|
|
62
63
|
parentId: string | null;
|
|
63
64
|
labels: string[];
|
|
65
|
+
duration: string | null;
|
|
64
66
|
}[];
|
|
65
67
|
nextCursor: string | null;
|
|
66
68
|
}>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-helpers.d.ts","sourceRoot":"","sources":["../src/tool-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,IAAI,EACT,KAAK,UAAU,EACf,KAAK,gBAAgB,EAExB,MAAM,+BAA+B,CAAA;
|
|
1
|
+
{"version":3,"file":"tool-helpers.d.ts","sourceRoot":"","sources":["../src/tool-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,IAAI,EACT,KAAK,UAAU,EACf,KAAK,gBAAgB,EAExB,MAAM,+BAA+B,CAAA;AAItC,MAAM,MAAM,OAAO,GAAG,eAAe,GAAG,gBAAgB,CAAA;AAExD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,eAAe,CAE9E;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,gBAAgB,CAEhF;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAC9B,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,GAClB,YAAY,CAsBd;AAED;;;;GAIG;AACH,iBAAS,OAAO,CAAC,IAAI,EAAE,IAAI;;;;;;;;;;;;EAc1B;AAED;;;;GAIG;AACH,iBAAS,UAAU,CAAC,OAAO,EAAE,OAAO;;;;;;;;;EAWnC;AAWD,iBAAe,gBAAgB,CAAC,EAC5B,MAAM,EACN,KAAK,EACL,KAAK,EACL,MAAM,GACT,EAAE;IACC,MAAM,EAAE,UAAU,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IACzB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;CAC7B;;;;;;;;;;;;;;;GAyBA;AAED,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,UAAU,EAAE,CAAA"}
|
package/dist/tool-helpers.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getSanitizedContent, } from '@doist/todoist-api-typescript';
|
|
2
2
|
import z from 'zod';
|
|
3
|
+
import { formatDuration } from './utils/duration-parser.js';
|
|
3
4
|
export function isPersonalProject(project) {
|
|
4
5
|
return 'inboxProject' in project;
|
|
5
6
|
}
|
|
@@ -51,6 +52,7 @@ function mapTask(task) {
|
|
|
51
52
|
sectionId: task.sectionId,
|
|
52
53
|
parentId: task.parentId,
|
|
53
54
|
labels: task.labels,
|
|
55
|
+
duration: task.duration ? formatDuration(task.duration.amount) : null,
|
|
54
56
|
};
|
|
55
57
|
}
|
|
56
58
|
/**
|
|
@@ -30,6 +30,7 @@ describe('shared utilities', () => {
|
|
|
30
30
|
sectionId: null,
|
|
31
31
|
parentId: null,
|
|
32
32
|
labels: ['work'],
|
|
33
|
+
duration: null,
|
|
33
34
|
});
|
|
34
35
|
});
|
|
35
36
|
it('should handle recurring tasks', () => {
|
|
@@ -52,6 +53,25 @@ describe('shared utilities', () => {
|
|
|
52
53
|
};
|
|
53
54
|
const result = mapTask(mockTask);
|
|
54
55
|
expect(result.recurring).toBe('every day');
|
|
56
|
+
expect(result.duration).toBe(null);
|
|
57
|
+
});
|
|
58
|
+
it('should handle task with duration', () => {
|
|
59
|
+
const mockTask = {
|
|
60
|
+
id: '789',
|
|
61
|
+
content: 'Task with duration',
|
|
62
|
+
description: '',
|
|
63
|
+
projectId: 'proj-1',
|
|
64
|
+
sectionId: null,
|
|
65
|
+
parentId: null,
|
|
66
|
+
labels: [],
|
|
67
|
+
priority: 1,
|
|
68
|
+
duration: {
|
|
69
|
+
amount: 150,
|
|
70
|
+
unit: 'minute',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const result = mapTask(mockTask);
|
|
74
|
+
expect(result.duration).toBe('2h30m');
|
|
55
75
|
});
|
|
56
76
|
});
|
|
57
77
|
describe('mapProject', () => {
|
|
@@ -129,8 +129,122 @@ describe('tasks-add-multiple tool', () => {
|
|
|
129
129
|
}),
|
|
130
130
|
]);
|
|
131
131
|
});
|
|
132
|
+
it('should add tasks with duration', async () => {
|
|
133
|
+
const mockApiResponse1 = createMockTask({
|
|
134
|
+
id: '8485093752',
|
|
135
|
+
content: 'Task with 2 hour duration',
|
|
136
|
+
duration: { amount: 120, unit: 'minute' },
|
|
137
|
+
url: 'https://todoist.com/showTask?id=8485093752',
|
|
138
|
+
addedAt: '2025-08-13T22:09:56.123456Z',
|
|
139
|
+
});
|
|
140
|
+
const mockApiResponse2 = createMockTask({
|
|
141
|
+
id: '8485093753',
|
|
142
|
+
content: 'Task with 45 minute duration',
|
|
143
|
+
duration: { amount: 45, unit: 'minute' },
|
|
144
|
+
url: 'https://todoist.com/showTask?id=8485093753',
|
|
145
|
+
addedAt: '2025-08-13T22:09:57.123456Z',
|
|
146
|
+
});
|
|
147
|
+
mockTodoistApi.addTask
|
|
148
|
+
.mockResolvedValueOnce(mockApiResponse1)
|
|
149
|
+
.mockResolvedValueOnce(mockApiResponse2);
|
|
150
|
+
const result = await tasksAddMultiple.execute({
|
|
151
|
+
projectId: '6cfCcrrCFg2xP94Q',
|
|
152
|
+
tasks: [
|
|
153
|
+
{
|
|
154
|
+
content: 'Task with 2 hour duration',
|
|
155
|
+
duration: '2h',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
content: 'Task with 45 minute duration',
|
|
159
|
+
duration: '45m',
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
}, mockTodoistApi);
|
|
163
|
+
// Verify API was called with parsed duration
|
|
164
|
+
expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(1, {
|
|
165
|
+
content: 'Task with 2 hour duration',
|
|
166
|
+
projectId: '6cfCcrrCFg2xP94Q',
|
|
167
|
+
sectionId: undefined,
|
|
168
|
+
parentId: undefined,
|
|
169
|
+
duration: 120,
|
|
170
|
+
durationUnit: 'minute',
|
|
171
|
+
});
|
|
172
|
+
expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(2, {
|
|
173
|
+
content: 'Task with 45 minute duration',
|
|
174
|
+
projectId: '6cfCcrrCFg2xP94Q',
|
|
175
|
+
sectionId: undefined,
|
|
176
|
+
parentId: undefined,
|
|
177
|
+
duration: 45,
|
|
178
|
+
durationUnit: 'minute',
|
|
179
|
+
});
|
|
180
|
+
// Verify result includes formatted duration
|
|
181
|
+
expect(result).toEqual([
|
|
182
|
+
expect.objectContaining({
|
|
183
|
+
id: '8485093752',
|
|
184
|
+
content: 'Task with 2 hour duration',
|
|
185
|
+
duration: '2h',
|
|
186
|
+
}),
|
|
187
|
+
expect.objectContaining({
|
|
188
|
+
id: '8485093753',
|
|
189
|
+
content: 'Task with 45 minute duration',
|
|
190
|
+
duration: '45m',
|
|
191
|
+
}),
|
|
192
|
+
]);
|
|
193
|
+
});
|
|
194
|
+
it('should handle various duration formats', async () => {
|
|
195
|
+
const mockApiResponse = createMockTask({
|
|
196
|
+
id: '8485093754',
|
|
197
|
+
content: 'Task with combined duration',
|
|
198
|
+
duration: { amount: 150, unit: 'minute' },
|
|
199
|
+
url: 'https://todoist.com/showTask?id=8485093754',
|
|
200
|
+
addedAt: '2025-08-13T22:09:56.123456Z',
|
|
201
|
+
});
|
|
202
|
+
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse);
|
|
203
|
+
// Test different duration formats
|
|
204
|
+
const testCases = [
|
|
205
|
+
{ input: '2h30m', expectedMinutes: 150 },
|
|
206
|
+
{ input: '1.5h', expectedMinutes: 90 },
|
|
207
|
+
{ input: ' 90m ', expectedMinutes: 90 },
|
|
208
|
+
{ input: '2H30M', expectedMinutes: 150 },
|
|
209
|
+
];
|
|
210
|
+
for (const testCase of testCases) {
|
|
211
|
+
mockTodoistApi.addTask.mockClear();
|
|
212
|
+
await tasksAddMultiple.execute({
|
|
213
|
+
tasks: [
|
|
214
|
+
{
|
|
215
|
+
content: 'Test task',
|
|
216
|
+
duration: testCase.input,
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
}, mockTodoistApi);
|
|
220
|
+
expect(mockTodoistApi.addTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
221
|
+
duration: testCase.expectedMinutes,
|
|
222
|
+
durationUnit: 'minute',
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
});
|
|
132
226
|
});
|
|
133
227
|
describe('error handling', () => {
|
|
228
|
+
it('should throw error for invalid duration format', async () => {
|
|
229
|
+
await expect(tasksAddMultiple.execute({
|
|
230
|
+
tasks: [
|
|
231
|
+
{
|
|
232
|
+
content: 'Task with invalid duration',
|
|
233
|
+
duration: 'invalid',
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
}, mockTodoistApi)).rejects.toThrow('Task "Task with invalid duration": Invalid duration format "invalid"');
|
|
237
|
+
});
|
|
238
|
+
it('should throw error for duration exceeding 24 hours', async () => {
|
|
239
|
+
await expect(tasksAddMultiple.execute({
|
|
240
|
+
tasks: [
|
|
241
|
+
{
|
|
242
|
+
content: 'Task with too long duration',
|
|
243
|
+
duration: '25h',
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
}, mockTodoistApi)).rejects.toThrow('Task "Task with too long duration": Invalid duration format "25h": Duration cannot exceed 24 hours (1440 minutes)');
|
|
247
|
+
});
|
|
134
248
|
it('should propagate API errors', async () => {
|
|
135
249
|
const apiError = new Error('API Error: Task content is required');
|
|
136
250
|
mockTodoistApi.addTask.mockRejectedValue(apiError);
|
|
@@ -135,8 +135,98 @@ describe('tasks-update-one tool', () => {
|
|
|
135
135
|
});
|
|
136
136
|
expect(result).toEqual(updatedTask);
|
|
137
137
|
});
|
|
138
|
+
it('should update task duration', async () => {
|
|
139
|
+
const mockApiResponse = createMockTask({
|
|
140
|
+
id: '8485093753',
|
|
141
|
+
content: 'Task with updated duration',
|
|
142
|
+
duration: { amount: 150, unit: 'minute' },
|
|
143
|
+
url: 'https://todoist.com/showTask?id=8485093753',
|
|
144
|
+
addedAt: '2025-08-13T22:09:56.123456Z',
|
|
145
|
+
});
|
|
146
|
+
mockTodoistApi.updateTask.mockResolvedValue(mockApiResponse);
|
|
147
|
+
const result = await tasksUpdateOne.execute({
|
|
148
|
+
id: '8485093753',
|
|
149
|
+
duration: '2h30m',
|
|
150
|
+
}, mockTodoistApi);
|
|
151
|
+
expect(mockTodoistApi.updateTask).toHaveBeenCalledWith('8485093753', {
|
|
152
|
+
duration: 150,
|
|
153
|
+
durationUnit: 'minute',
|
|
154
|
+
});
|
|
155
|
+
expect(result).toEqual(mockApiResponse);
|
|
156
|
+
});
|
|
157
|
+
it('should handle various duration formats', async () => {
|
|
158
|
+
const mockApiResponse = createMockTask({
|
|
159
|
+
id: '8485093754',
|
|
160
|
+
content: 'Test task',
|
|
161
|
+
duration: { amount: 120, unit: 'minute' },
|
|
162
|
+
});
|
|
163
|
+
mockTodoistApi.updateTask.mockResolvedValue(mockApiResponse);
|
|
164
|
+
// Test different duration formats
|
|
165
|
+
const testCases = [
|
|
166
|
+
{ input: '2h', expectedMinutes: 120 },
|
|
167
|
+
{ input: '90m', expectedMinutes: 90 },
|
|
168
|
+
{ input: '1.5h', expectedMinutes: 90 },
|
|
169
|
+
{ input: ' 2h 30m ', expectedMinutes: 150 },
|
|
170
|
+
{ input: '2H30M', expectedMinutes: 150 },
|
|
171
|
+
];
|
|
172
|
+
for (const testCase of testCases) {
|
|
173
|
+
mockTodoistApi.updateTask.mockClear();
|
|
174
|
+
await tasksUpdateOne.execute({
|
|
175
|
+
id: '8485093754',
|
|
176
|
+
duration: testCase.input,
|
|
177
|
+
}, mockTodoistApi);
|
|
178
|
+
expect(mockTodoistApi.updateTask).toHaveBeenCalledWith('8485093754', expect.objectContaining({
|
|
179
|
+
duration: testCase.expectedMinutes,
|
|
180
|
+
durationUnit: 'minute',
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
it('should update task with duration and move at once', async () => {
|
|
185
|
+
const movedTask = createMockTask({
|
|
186
|
+
id: '8485093755',
|
|
187
|
+
content: 'Task to move and update',
|
|
188
|
+
projectId: 'new-project-id',
|
|
189
|
+
});
|
|
190
|
+
const updatedTask = createMockTask({
|
|
191
|
+
id: '8485093755',
|
|
192
|
+
content: 'Updated task with duration',
|
|
193
|
+
duration: { amount: 120, unit: 'minute' },
|
|
194
|
+
projectId: 'new-project-id',
|
|
195
|
+
});
|
|
196
|
+
mockTodoistApi.moveTasks.mockResolvedValue([movedTask]);
|
|
197
|
+
mockTodoistApi.updateTask.mockResolvedValue(updatedTask);
|
|
198
|
+
const result = await tasksUpdateOne.execute({
|
|
199
|
+
id: '8485093755',
|
|
200
|
+
content: 'Updated task with duration',
|
|
201
|
+
duration: '2h',
|
|
202
|
+
projectId: 'new-project-id',
|
|
203
|
+
}, mockTodoistApi);
|
|
204
|
+
// Should call moveTasks first
|
|
205
|
+
expect(mockTodoistApi.moveTasks).toHaveBeenCalledWith(['8485093755'], {
|
|
206
|
+
projectId: 'new-project-id',
|
|
207
|
+
});
|
|
208
|
+
// Then call updateTask with duration
|
|
209
|
+
expect(mockTodoistApi.updateTask).toHaveBeenCalledWith('8485093755', {
|
|
210
|
+
content: 'Updated task with duration',
|
|
211
|
+
duration: 120,
|
|
212
|
+
durationUnit: 'minute',
|
|
213
|
+
});
|
|
214
|
+
expect(result).toEqual(updatedTask);
|
|
215
|
+
});
|
|
138
216
|
});
|
|
139
217
|
describe('error handling', () => {
|
|
218
|
+
it('should throw error for invalid duration format', async () => {
|
|
219
|
+
await expect(tasksUpdateOne.execute({
|
|
220
|
+
id: '8485093756',
|
|
221
|
+
duration: 'invalid',
|
|
222
|
+
}, mockTodoistApi)).rejects.toThrow('Task 8485093756: Invalid duration format "invalid"');
|
|
223
|
+
});
|
|
224
|
+
it('should throw error for duration exceeding 24 hours', async () => {
|
|
225
|
+
await expect(tasksUpdateOne.execute({
|
|
226
|
+
id: '8485093757',
|
|
227
|
+
duration: '25h',
|
|
228
|
+
}, mockTodoistApi)).rejects.toThrow('Task 8485093757: Invalid duration format "25h": Duration cannot exceed 24 hours (1440 minutes)');
|
|
229
|
+
});
|
|
140
230
|
it('should throw error when multiple move parameters are provided', async () => {
|
|
141
231
|
await expect(tasksUpdateOne.execute({ id: '8485093748', projectId: 'new-project', sectionId: 'new-section' }, mockTodoistApi)).rejects.toThrow('Only one of projectId, sectionId, or parentId can be specified at a time. ' +
|
|
142
232
|
'The Todoist API requires exactly one destination for move operations.');
|
|
@@ -11,16 +11,19 @@ declare const tasksAddMultiple: {
|
|
|
11
11
|
description: z.ZodOptional<z.ZodString>;
|
|
12
12
|
priority: z.ZodOptional<z.ZodNumber>;
|
|
13
13
|
dueString: z.ZodOptional<z.ZodString>;
|
|
14
|
+
duration: z.ZodOptional<z.ZodString>;
|
|
14
15
|
}, "strip", z.ZodTypeAny, {
|
|
15
16
|
content: string;
|
|
16
17
|
description?: string | undefined;
|
|
17
18
|
priority?: number | undefined;
|
|
18
19
|
dueString?: string | undefined;
|
|
20
|
+
duration?: string | undefined;
|
|
19
21
|
}, {
|
|
20
22
|
content: string;
|
|
21
23
|
description?: string | undefined;
|
|
22
24
|
priority?: number | undefined;
|
|
23
25
|
dueString?: string | undefined;
|
|
26
|
+
duration?: string | undefined;
|
|
24
27
|
}>, "many">;
|
|
25
28
|
};
|
|
26
29
|
execute(args: {
|
|
@@ -29,6 +32,7 @@ declare const tasksAddMultiple: {
|
|
|
29
32
|
description?: string | undefined;
|
|
30
33
|
priority?: number | undefined;
|
|
31
34
|
dueString?: string | undefined;
|
|
35
|
+
duration?: string | undefined;
|
|
32
36
|
}[];
|
|
33
37
|
parentId?: string | undefined;
|
|
34
38
|
projectId?: string | undefined;
|
|
@@ -44,6 +48,7 @@ declare const tasksAddMultiple: {
|
|
|
44
48
|
sectionId: string | null;
|
|
45
49
|
parentId: string | null;
|
|
46
50
|
labels: string[];
|
|
51
|
+
duration: string | null;
|
|
47
52
|
}[]>;
|
|
48
53
|
};
|
|
49
54
|
export { tasksAddMultiple };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tasks-add-multiple.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-add-multiple.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;
|
|
1
|
+
{"version":3,"file":"tasks-add-multiple.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-add-multiple.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAyBvB,QAAA,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCoB,CAAA;AAE1C,OAAO,EAAE,gBAAgB,EAAE,CAAA"}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { mapTask } from '../tool-helpers.js';
|
|
3
|
+
import { DurationParseError, parseDuration } from '../utils/duration-parser.js';
|
|
3
4
|
const TaskSchema = z.object({
|
|
4
5
|
content: z.string().min(1).describe('The content of the task to create.'),
|
|
5
6
|
description: z.string().optional().describe('The description of the task.'),
|
|
6
7
|
priority: z.number().int().min(1).max(4).optional().describe('The priority of the task (1-4).'),
|
|
7
8
|
dueString: z.string().optional().describe('The due date for the task, in natural language.'),
|
|
9
|
+
duration: z
|
|
10
|
+
.string()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe('The duration of the task. Use format: "2h" (hours), "90m" (minutes), "2h30m" (combined), or "1.5h" (decimal hours). Max 24h.'),
|
|
8
13
|
});
|
|
9
14
|
const ArgsSchema = {
|
|
10
15
|
projectId: z.string().optional().describe('The project ID to add the tasks to.'),
|
|
@@ -20,7 +25,25 @@ const tasksAddMultiple = {
|
|
|
20
25
|
const { projectId, sectionId, parentId, tasks } = args;
|
|
21
26
|
const newTasks = [];
|
|
22
27
|
for (const task of tasks) {
|
|
23
|
-
const
|
|
28
|
+
const { duration: durationStr, ...otherTaskArgs } = task;
|
|
29
|
+
let taskArgs = { ...otherTaskArgs, projectId, sectionId, parentId };
|
|
30
|
+
// Parse duration if provided
|
|
31
|
+
if (durationStr) {
|
|
32
|
+
try {
|
|
33
|
+
const { minutes } = parseDuration(durationStr);
|
|
34
|
+
taskArgs = {
|
|
35
|
+
...taskArgs,
|
|
36
|
+
duration: minutes,
|
|
37
|
+
durationUnit: 'minute',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error instanceof DurationParseError) {
|
|
42
|
+
throw new Error(`Task "${task.content}": ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
24
47
|
newTasks.push(await client.addTask(taskArgs));
|
|
25
48
|
}
|
|
26
49
|
return newTasks.map(mapTask);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tasks-list-by-date.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-by-date.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAmCvB,QAAA,MAAM,eAAe
|
|
1
|
+
{"version":3,"file":"tasks-list-by-date.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-by-date.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAmCvB,QAAA,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BqB,CAAA;AAE1C,OAAO,EAAE,eAAe,EAAE,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tasks-list-completed.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-completed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwCvB,QAAA,MAAM,kBAAkB
|
|
1
|
+
{"version":3,"file":"tasks-list-completed.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-completed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwCvB,QAAA,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAekB,CAAA;AAE1C,OAAO,EAAE,kBAAkB,EAAE,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tasks-list-for-container.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-for-container.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwBvB,QAAA,MAAM,qBAAqB
|
|
1
|
+
{"version":3,"file":"tasks-list-for-container.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-list-for-container.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAwBvB,QAAA,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+Be,CAAA;AAE1C,OAAO,EAAE,qBAAqB,EAAE,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tasks-search.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAqBvB,QAAA,MAAM,WAAW
|
|
1
|
+
{"version":3,"file":"tasks-search.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAqBvB,QAAA,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAYyB,CAAA;AAE1C,OAAO,EAAE,WAAW,EAAE,CAAA"}
|
|
@@ -11,6 +11,7 @@ declare const tasksUpdateOne: {
|
|
|
11
11
|
parentId: z.ZodOptional<z.ZodString>;
|
|
12
12
|
priority: z.ZodOptional<z.ZodNumber>;
|
|
13
13
|
dueString: z.ZodOptional<z.ZodString>;
|
|
14
|
+
duration: z.ZodOptional<z.ZodString>;
|
|
14
15
|
};
|
|
15
16
|
execute(args: {
|
|
16
17
|
id: string;
|
|
@@ -21,6 +22,7 @@ declare const tasksUpdateOne: {
|
|
|
21
22
|
sectionId?: string | undefined;
|
|
22
23
|
priority?: number | undefined;
|
|
23
24
|
dueString?: string | undefined;
|
|
25
|
+
duration?: string | undefined;
|
|
24
26
|
}, client: import("@doist/todoist-api-typescript").TodoistApi): Promise<import("@doist/todoist-api-typescript").Task | undefined>;
|
|
25
27
|
};
|
|
26
28
|
export { tasksUpdateOne };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tasks-update-one.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-update-one.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"tasks-update-one.d.ts","sourceRoot":"","sources":["../../src/tools/tasks-update-one.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AA+BvB,QAAA,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;CA+CsB,CAAA;AAE1C,OAAO,EAAE,cAAc,EAAE,CAAA"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { createMoveTaskArgs } from '../tool-helpers.js';
|
|
3
|
+
import { DurationParseError, parseDuration } from '../utils/duration-parser.js';
|
|
3
4
|
const ArgsSchema = {
|
|
4
5
|
id: z.string().min(1).describe('The ID of the task to update.'),
|
|
5
6
|
content: z.string().optional().describe('The new content of the task.'),
|
|
@@ -18,13 +19,35 @@ const ArgsSchema = {
|
|
|
18
19
|
.string()
|
|
19
20
|
.optional()
|
|
20
21
|
.describe("The new due date for the task, in natural language (e.g., 'tomorrow at 5pm')."),
|
|
22
|
+
duration: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('The duration of the task. Use format: "2h" (hours), "90m" (minutes), "2h30m" (combined), or "1.5h" (decimal hours). Max 24h.'),
|
|
21
26
|
};
|
|
22
27
|
const tasksUpdateOne = {
|
|
23
28
|
name: 'tasks-update-one',
|
|
24
29
|
description: 'Update an existing task with new values.',
|
|
25
30
|
parameters: ArgsSchema,
|
|
26
31
|
async execute(args, client) {
|
|
27
|
-
const { id, projectId, sectionId, parentId, ...
|
|
32
|
+
const { id, projectId, sectionId, parentId, duration: durationStr, ...otherUpdateArgs } = args;
|
|
33
|
+
let updateArgs = { ...otherUpdateArgs };
|
|
34
|
+
// Parse duration if provided
|
|
35
|
+
if (durationStr) {
|
|
36
|
+
try {
|
|
37
|
+
const { minutes } = parseDuration(durationStr);
|
|
38
|
+
updateArgs = {
|
|
39
|
+
...updateArgs,
|
|
40
|
+
duration: minutes,
|
|
41
|
+
durationUnit: 'minute',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof DurationParseError) {
|
|
46
|
+
throw new Error(`Task ${id}: ${error.message}`);
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
28
51
|
// If no move parameters are provided, use updateTask without moveTasks
|
|
29
52
|
if (!projectId && !sectionId && !parentId) {
|
|
30
53
|
return await client.updateTask(id, updateArgs);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["../../src/tools/test-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAEnF;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAA;IAC3B,SAAS,EAAE,MAAM,GAAG,OAAO,CAAA;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,MAAM,EAAE,MAAM,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["../../src/tools/test-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAEnF;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAA;IAC3B,SAAS,EAAE,MAAM,GAAG,OAAO,CAAA;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B,CAAA;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,GAAE,OAAO,CAAC,IAAI,CAAM,GAAG,IAAI,CA8BlE;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,OAAO,CAAC,OAAO,CAAM,GAAG,OAAO,CAe3E;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,OAAO,CAAC,eAAe,CAAM,GAAG,eAAe,CAuB3F;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EACnC,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,GAAE,MAAM,GAAG,IAAW,GACjC;IACC,OAAO,EAAE,CAAC,EAAE,CAAA;IACZ,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B,CAKA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,GAAE,OAAO,CAAC,UAAU,CAAM,GAAG,UAAU,CAehF;AAED;;GAEG;AACH,eAAO,MAAM,WAAW;;;;;CAKd,CAAA;AAEV;;GAEG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,EAC1C,KAAK,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,CAAA;CAAE,CAAC;UAAjC,MAAM;WAAS,CAAC;eAAa,CAAC;IAGtD;AAED;;GAEG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;;CAUX,CAAA"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration parser utility for converting human-readable duration strings
|
|
3
|
+
* to minutes using a restricted, language-neutral syntax.
|
|
4
|
+
*
|
|
5
|
+
* Supported formats:
|
|
6
|
+
* - "2h" (hours only)
|
|
7
|
+
* - "90m" (minutes only)
|
|
8
|
+
* - "2h30m" (hours + minutes)
|
|
9
|
+
* - "1.5h" (decimal hours)
|
|
10
|
+
* - Supports optional spaces: "2h 30m"
|
|
11
|
+
*/
|
|
12
|
+
interface ParsedDuration {
|
|
13
|
+
minutes: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class DurationParseError extends Error {
|
|
16
|
+
constructor(input: string, reason: string);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parses duration string in restricted syntax to minutes.
|
|
20
|
+
* Max duration: 1440 minutes (24 hours)
|
|
21
|
+
*
|
|
22
|
+
* @param durationStr - Duration string like "2h30m", "45m", "1.5h"
|
|
23
|
+
* @returns Parsed duration in minutes
|
|
24
|
+
* @throws DurationParseError for invalid formats
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseDuration(durationStr: string): ParsedDuration;
|
|
27
|
+
/**
|
|
28
|
+
* Formats minutes back to a human-readable duration string.
|
|
29
|
+
* Used when returning task data to LLMs.
|
|
30
|
+
*
|
|
31
|
+
* @param minutes - Duration in minutes
|
|
32
|
+
* @returns Formatted duration string like "2h30m" or "45m"
|
|
33
|
+
*/
|
|
34
|
+
export declare function formatDuration(minutes: number): string;
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=duration-parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duration-parser.d.ts","sourceRoot":"","sources":["../../src/utils/duration-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,UAAU,cAAc;IACpB,OAAO,EAAE,MAAM,CAAA;CAClB;AAED,qBAAa,kBAAmB,SAAQ,KAAK;gBAC7B,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAI5C;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,CAgEjE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAetD"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration parser utility for converting human-readable duration strings
|
|
3
|
+
* to minutes using a restricted, language-neutral syntax.
|
|
4
|
+
*
|
|
5
|
+
* Supported formats:
|
|
6
|
+
* - "2h" (hours only)
|
|
7
|
+
* - "90m" (minutes only)
|
|
8
|
+
* - "2h30m" (hours + minutes)
|
|
9
|
+
* - "1.5h" (decimal hours)
|
|
10
|
+
* - Supports optional spaces: "2h 30m"
|
|
11
|
+
*/
|
|
12
|
+
export class DurationParseError extends Error {
|
|
13
|
+
constructor(input, reason) {
|
|
14
|
+
super(`Invalid duration format "${input}": ${reason}`);
|
|
15
|
+
this.name = 'DurationParseError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parses duration string in restricted syntax to minutes.
|
|
20
|
+
* Max duration: 1440 minutes (24 hours)
|
|
21
|
+
*
|
|
22
|
+
* @param durationStr - Duration string like "2h30m", "45m", "1.5h"
|
|
23
|
+
* @returns Parsed duration in minutes
|
|
24
|
+
* @throws DurationParseError for invalid formats
|
|
25
|
+
*/
|
|
26
|
+
export function parseDuration(durationStr) {
|
|
27
|
+
if (!durationStr || typeof durationStr !== 'string') {
|
|
28
|
+
throw new DurationParseError(durationStr, 'Duration must be a non-empty string');
|
|
29
|
+
}
|
|
30
|
+
// Remove all spaces and convert to lowercase
|
|
31
|
+
const normalized = durationStr.trim().toLowerCase().replace(/\s+/g, '');
|
|
32
|
+
// Check for empty string after trimming
|
|
33
|
+
if (!normalized) {
|
|
34
|
+
throw new DurationParseError(durationStr, 'Duration must be a non-empty string');
|
|
35
|
+
}
|
|
36
|
+
// Validate format with strict ordering: hours must come before minutes
|
|
37
|
+
// This regex ensures: optional hours followed by optional minutes, no duplicates
|
|
38
|
+
const match = normalized.match(/^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?$/);
|
|
39
|
+
if (!match || (!match[1] && !match[2])) {
|
|
40
|
+
throw new DurationParseError(durationStr, 'Use format like "2h", "30m", "2h30m", or "1.5h"');
|
|
41
|
+
}
|
|
42
|
+
let totalMinutes = 0;
|
|
43
|
+
const [, hoursStr, minutesStr] = match;
|
|
44
|
+
// Parse hours if present
|
|
45
|
+
if (hoursStr) {
|
|
46
|
+
const hours = Number.parseFloat(hoursStr);
|
|
47
|
+
if (Number.isNaN(hours) || hours < 0) {
|
|
48
|
+
throw new DurationParseError(durationStr, 'Hours must be a positive number');
|
|
49
|
+
}
|
|
50
|
+
totalMinutes += hours * 60;
|
|
51
|
+
}
|
|
52
|
+
// Parse minutes if present
|
|
53
|
+
if (minutesStr) {
|
|
54
|
+
const minutes = Number.parseFloat(minutesStr);
|
|
55
|
+
if (Number.isNaN(minutes) || minutes < 0) {
|
|
56
|
+
throw new DurationParseError(durationStr, 'Minutes must be a positive number');
|
|
57
|
+
}
|
|
58
|
+
// Don't allow decimal minutes
|
|
59
|
+
if (minutes % 1 !== 0) {
|
|
60
|
+
throw new DurationParseError(durationStr, 'Minutes must be a whole number (use decimal hours instead)');
|
|
61
|
+
}
|
|
62
|
+
totalMinutes += minutes;
|
|
63
|
+
}
|
|
64
|
+
// The regex already ensures at least one unit is present
|
|
65
|
+
// Round to nearest minute (handles decimal hours)
|
|
66
|
+
totalMinutes = Math.round(totalMinutes);
|
|
67
|
+
// Validate minimum duration
|
|
68
|
+
if (totalMinutes === 0) {
|
|
69
|
+
throw new DurationParseError(durationStr, 'Duration must be greater than 0 minutes');
|
|
70
|
+
}
|
|
71
|
+
// Validate maximum duration (24 hours = 1440 minutes)
|
|
72
|
+
if (totalMinutes > 1440) {
|
|
73
|
+
throw new DurationParseError(durationStr, 'Duration cannot exceed 24 hours (1440 minutes)');
|
|
74
|
+
}
|
|
75
|
+
return { minutes: totalMinutes };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Formats minutes back to a human-readable duration string.
|
|
79
|
+
* Used when returning task data to LLMs.
|
|
80
|
+
*
|
|
81
|
+
* @param minutes - Duration in minutes
|
|
82
|
+
* @returns Formatted duration string like "2h30m" or "45m"
|
|
83
|
+
*/
|
|
84
|
+
export function formatDuration(minutes) {
|
|
85
|
+
if (minutes <= 0)
|
|
86
|
+
return '0m';
|
|
87
|
+
const hours = Math.floor(minutes / 60);
|
|
88
|
+
const remainingMinutes = minutes % 60;
|
|
89
|
+
if (hours === 0) {
|
|
90
|
+
return `${remainingMinutes}m`;
|
|
91
|
+
}
|
|
92
|
+
if (remainingMinutes === 0) {
|
|
93
|
+
return `${hours}h`;
|
|
94
|
+
}
|
|
95
|
+
return `${hours}h${remainingMinutes}m`;
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duration-parser.test.d.ts","sourceRoot":"","sources":["../../src/utils/duration-parser.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { DurationParseError, formatDuration, parseDuration } from './duration-parser.js';
|
|
2
|
+
describe('parseDuration', () => {
|
|
3
|
+
describe('valid formats', () => {
|
|
4
|
+
it('should parse hours only', () => {
|
|
5
|
+
expect(parseDuration('2h')).toEqual({ minutes: 120 });
|
|
6
|
+
expect(parseDuration('1h')).toEqual({ minutes: 60 });
|
|
7
|
+
expect(parseDuration('24h')).toEqual({ minutes: 1440 });
|
|
8
|
+
});
|
|
9
|
+
it('should parse minutes only', () => {
|
|
10
|
+
expect(parseDuration('90m')).toEqual({ minutes: 90 });
|
|
11
|
+
expect(parseDuration('45m')).toEqual({ minutes: 45 });
|
|
12
|
+
expect(parseDuration('1m')).toEqual({ minutes: 1 });
|
|
13
|
+
expect(parseDuration('1440m')).toEqual({ minutes: 1440 });
|
|
14
|
+
});
|
|
15
|
+
it('should parse hours and minutes combined', () => {
|
|
16
|
+
expect(parseDuration('2h30m')).toEqual({ minutes: 150 });
|
|
17
|
+
expect(parseDuration('1h45m')).toEqual({ minutes: 105 });
|
|
18
|
+
expect(parseDuration('0h30m')).toEqual({ minutes: 30 });
|
|
19
|
+
expect(parseDuration('23h59m')).toEqual({ minutes: 1439 });
|
|
20
|
+
});
|
|
21
|
+
it('should parse decimal hours', () => {
|
|
22
|
+
expect(parseDuration('1.5h')).toEqual({ minutes: 90 });
|
|
23
|
+
expect(parseDuration('2.25h')).toEqual({ minutes: 135 });
|
|
24
|
+
expect(parseDuration('0.5h')).toEqual({ minutes: 30 });
|
|
25
|
+
expect(parseDuration('0.75h')).toEqual({ minutes: 45 });
|
|
26
|
+
});
|
|
27
|
+
it('should handle spaces in input', () => {
|
|
28
|
+
expect(parseDuration('2h 30m')).toEqual({ minutes: 150 });
|
|
29
|
+
expect(parseDuration(' 1h45m ')).toEqual({ minutes: 105 });
|
|
30
|
+
expect(parseDuration(' 2h ')).toEqual({ minutes: 120 });
|
|
31
|
+
expect(parseDuration(' 90m ')).toEqual({ minutes: 90 });
|
|
32
|
+
});
|
|
33
|
+
it('should handle case insensitive input', () => {
|
|
34
|
+
expect(parseDuration('2H')).toEqual({ minutes: 120 });
|
|
35
|
+
expect(parseDuration('90M')).toEqual({ minutes: 90 });
|
|
36
|
+
expect(parseDuration('2H30M')).toEqual({ minutes: 150 });
|
|
37
|
+
expect(parseDuration('1.5H')).toEqual({ minutes: 90 });
|
|
38
|
+
});
|
|
39
|
+
it('should round decimal minutes from decimal hours', () => {
|
|
40
|
+
expect(parseDuration('1.33h')).toEqual({ minutes: 80 }); // 1.33 * 60 = 79.8 -> 80
|
|
41
|
+
expect(parseDuration('1.67h')).toEqual({ minutes: 100 }); // 1.67 * 60 = 100.2 -> 100
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('invalid formats', () => {
|
|
45
|
+
it('should throw error for empty or null input', () => {
|
|
46
|
+
expect(() => parseDuration('')).toThrow(DurationParseError);
|
|
47
|
+
expect(() => parseDuration(' ')).toThrow('Duration must be a non-empty string');
|
|
48
|
+
// biome-ignore lint/suspicious/noExplicitAny: Testing error cases with invalid types
|
|
49
|
+
expect(() => parseDuration(null)).toThrow('Duration must be a non-empty string');
|
|
50
|
+
// biome-ignore lint/suspicious/noExplicitAny: Testing error cases with invalid types
|
|
51
|
+
expect(() => parseDuration(undefined)).toThrow('Duration must be a non-empty string');
|
|
52
|
+
});
|
|
53
|
+
it('should throw error for invalid format', () => {
|
|
54
|
+
expect(() => parseDuration('2')).toThrow('Use format like "2h", "30m", "2h30m", or "1.5h"');
|
|
55
|
+
expect(() => parseDuration('2hours')).toThrow('Use format like');
|
|
56
|
+
expect(() => parseDuration('2h30')).toThrow('Use format like');
|
|
57
|
+
expect(() => parseDuration('h30m')).toThrow('Use format like');
|
|
58
|
+
expect(() => parseDuration('2x30m')).toThrow('Use format like');
|
|
59
|
+
expect(() => parseDuration('2h30s')).toThrow('Use format like');
|
|
60
|
+
});
|
|
61
|
+
it('should throw error for decimal minutes', () => {
|
|
62
|
+
expect(() => parseDuration('90.5m')).toThrow('Minutes must be a whole number');
|
|
63
|
+
expect(() => parseDuration('1h30.5m')).toThrow('Minutes must be a whole number');
|
|
64
|
+
});
|
|
65
|
+
it('should throw error for negative values', () => {
|
|
66
|
+
expect(() => parseDuration('-2h')).toThrow('Use format like');
|
|
67
|
+
expect(() => parseDuration('-30m')).toThrow('Use format like');
|
|
68
|
+
expect(() => parseDuration('2h-30m')).toThrow('Use format like');
|
|
69
|
+
});
|
|
70
|
+
it('should throw error for zero duration', () => {
|
|
71
|
+
expect(() => parseDuration('0h')).toThrow('Duration must be greater than 0 minutes');
|
|
72
|
+
expect(() => parseDuration('0m')).toThrow('Duration must be greater than 0 minutes');
|
|
73
|
+
expect(() => parseDuration('0h0m')).toThrow('Duration must be greater than 0 minutes');
|
|
74
|
+
});
|
|
75
|
+
it('should throw error for duration exceeding 24 hours', () => {
|
|
76
|
+
expect(() => parseDuration('25h')).toThrow('Duration cannot exceed 24 hours (1440 minutes)');
|
|
77
|
+
expect(() => parseDuration('1441m')).toThrow('Duration cannot exceed 24 hours (1440 minutes)');
|
|
78
|
+
expect(() => parseDuration('24h1m')).toThrow('Duration cannot exceed 24 hours (1440 minutes)');
|
|
79
|
+
expect(() => parseDuration('24.1h')).toThrow('Duration cannot exceed 24 hours (1440 minutes)');
|
|
80
|
+
});
|
|
81
|
+
it('should throw error for malformed numbers', () => {
|
|
82
|
+
expect(() => parseDuration('2.h')).toThrow('Use format like');
|
|
83
|
+
expect(() => parseDuration('2h.m')).toThrow('Use format like');
|
|
84
|
+
expect(() => parseDuration('2..5h')).toThrow('Use format like');
|
|
85
|
+
});
|
|
86
|
+
it('should throw error for duplicate units', () => {
|
|
87
|
+
expect(() => parseDuration('2h3h')).toThrow('Use format like');
|
|
88
|
+
expect(() => parseDuration('30m45m')).toThrow('Use format like');
|
|
89
|
+
});
|
|
90
|
+
it('should throw error for wrong order (minutes before hours)', () => {
|
|
91
|
+
expect(() => parseDuration('30m2h')).toThrow('Use format like');
|
|
92
|
+
expect(() => parseDuration('45m1h')).toThrow('Use format like');
|
|
93
|
+
});
|
|
94
|
+
it('should throw error for invalid mixed formats with correct order', () => {
|
|
95
|
+
expect(() => parseDuration('2h30m15h')).toThrow('Use format like');
|
|
96
|
+
expect(() => parseDuration('1h2h30m')).toThrow('Use format like');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('edge cases', () => {
|
|
100
|
+
it('should handle maximum allowed duration', () => {
|
|
101
|
+
expect(parseDuration('24h')).toEqual({ minutes: 1440 });
|
|
102
|
+
expect(parseDuration('1440m')).toEqual({ minutes: 1440 });
|
|
103
|
+
expect(parseDuration('23h60m')).toEqual({ minutes: 1440 });
|
|
104
|
+
});
|
|
105
|
+
it('should handle minimum allowed duration', () => {
|
|
106
|
+
expect(parseDuration('1m')).toEqual({ minutes: 1 });
|
|
107
|
+
expect(parseDuration('0.017h')).toEqual({ minutes: 1 }); // 0.017 * 60 = 1.02 -> 1
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('formatDuration', () => {
|
|
112
|
+
it('should format minutes only', () => {
|
|
113
|
+
expect(formatDuration(45)).toBe('45m');
|
|
114
|
+
expect(formatDuration(1)).toBe('1m');
|
|
115
|
+
expect(formatDuration(59)).toBe('59m');
|
|
116
|
+
});
|
|
117
|
+
it('should format hours only', () => {
|
|
118
|
+
expect(formatDuration(60)).toBe('1h');
|
|
119
|
+
expect(formatDuration(120)).toBe('2h');
|
|
120
|
+
expect(formatDuration(1440)).toBe('24h');
|
|
121
|
+
});
|
|
122
|
+
it('should format hours and minutes combined', () => {
|
|
123
|
+
expect(formatDuration(90)).toBe('1h30m');
|
|
124
|
+
expect(formatDuration(150)).toBe('2h30m');
|
|
125
|
+
expect(formatDuration(105)).toBe('1h45m');
|
|
126
|
+
expect(formatDuration(1439)).toBe('23h59m');
|
|
127
|
+
});
|
|
128
|
+
it('should handle edge cases', () => {
|
|
129
|
+
expect(formatDuration(0)).toBe('0m');
|
|
130
|
+
expect(formatDuration(-5)).toBe('0m');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe('round trip parsing and formatting', () => {
|
|
134
|
+
const testCases = [
|
|
135
|
+
{ input: '2h', expectedMinutes: 120, expectedFormat: '2h' },
|
|
136
|
+
{ input: '45m', expectedMinutes: 45, expectedFormat: '45m' },
|
|
137
|
+
{ input: '2h30m', expectedMinutes: 150, expectedFormat: '2h30m' },
|
|
138
|
+
{ input: '1.5h', expectedMinutes: 90, expectedFormat: '1h30m' },
|
|
139
|
+
];
|
|
140
|
+
for (const { input, expectedMinutes, expectedFormat } of testCases) {
|
|
141
|
+
it(`should parse "${input}" and format back consistently`, () => {
|
|
142
|
+
const parsed = parseDuration(input);
|
|
143
|
+
expect(parsed.minutes).toBe(expectedMinutes);
|
|
144
|
+
expect(formatDuration(parsed.minutes)).toBe(expectedFormat);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|