Skip to content

Commit d45cf8f

Browse files
scottlovegrovepedroalves0
authored andcommitted
feat: Add moveTasks function (#270)
1 parent 47e9da7 commit d45cf8f

File tree

6 files changed

+131
-2
lines changed

6 files changed

+131
-2
lines changed

src/TodoistApi.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
GetSharedLabelsResponse,
3131
GetCommentsResponse,
3232
QuickAddTaskResponse,
33+
type MoveTaskArgs,
3334
} from './types/requests'
3435
import { request, isSuccess } from './restClient'
3536
import { getTaskFromQuickAddResponse } from './utils/taskConverters'
@@ -47,6 +48,7 @@ import {
4748
ENDPOINT_REST_LABELS_SHARED,
4849
ENDPOINT_REST_LABELS_SHARED_RENAME,
4950
ENDPOINT_REST_LABELS_SHARED_REMOVE,
51+
ENDPOINT_SYNC,
5052
} from './consts/endpoints'
5153
import {
5254
validateComment,
@@ -63,6 +65,12 @@ import {
6365
} from './utils/validators'
6466
import { z } from 'zod'
6567

68+
import { v4 as uuidv4 } from 'uuid'
69+
import { SyncResponse, type Command, type SyncRequest } from './types/sync'
70+
import { TodoistRequestError } from './types'
71+
72+
const MAX_COMMAND_COUNT = 100
73+
6674
/**
6775
* Joins path segments using `/` separator.
6876
* @param segments A list of **valid** path segments.
@@ -211,6 +219,67 @@ export class TodoistApi {
211219
return validateTask(response.data)
212220
}
213221

222+
/**
223+
* Moves existing tasks by their ID to either a different parent/section/project.
224+
*
225+
* @param ids - The unique identifier of the tasks to be moved.
226+
* @param args - The paramets that should contain only one of projectId, sectionId, or parentId
227+
* @param requestId - Optional unique identifier for idempotency.
228+
* @returns - A promise that resolves to an array of the updated tasks.
229+
*/
230+
async moveTasks(ids: string[], args: MoveTaskArgs, requestId?: string): Promise<Task[]> {
231+
if (ids.length > MAX_COMMAND_COUNT) {
232+
throw new TodoistRequestError(`Maximum number of items is ${MAX_COMMAND_COUNT}`, 400)
233+
}
234+
const uuid = uuidv4()
235+
const commands: Command[] = ids.map((id) => ({
236+
type: 'item_move',
237+
uuid,
238+
args: {
239+
id,
240+
...(args.projectId && { project_id: args.projectId }),
241+
...(args.sectionId && { section_id: args.sectionId }),
242+
...(args.parentId && { parent_id: args.parentId }),
243+
},
244+
}))
245+
246+
const syncRequest: SyncRequest = {
247+
commands,
248+
resource_types: ['items'],
249+
}
250+
251+
const response = await request<SyncResponse>(
252+
'POST',
253+
this.syncApiBase,
254+
ENDPOINT_SYNC,
255+
this.authToken,
256+
syncRequest,
257+
requestId,
258+
/*hasSyncCommands: */ true,
259+
)
260+
261+
if (response.data.sync_status) {
262+
Object.entries(response.data.sync_status).forEach(([_, value]) => {
263+
if (value === 'ok') return
264+
265+
throw new TodoistRequestError(value.error, value.http_code, value.error_extra)
266+
})
267+
}
268+
269+
if (!response.data.items?.length) {
270+
throw new TodoistRequestError('Tasks not found', 404)
271+
}
272+
273+
const syncTasks = response.data.items.filter((task) => ids.includes(task.id))
274+
if (!syncTasks.length) {
275+
throw new TodoistRequestError('Tasks not found', 404)
276+
}
277+
278+
const tasks = syncTasks.map(getTaskFromQuickAddResponse)
279+
280+
return validateTaskArray(tasks)
281+
}
282+
214283
/**
215284
* Closes (completes) a task by its ID.
216285
*

src/consts/endpoints.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export const ENDPOINT_REST_PROJECT_COLLABORATORS = 'collaborators'
3030

3131
export const ENDPOINT_SYNC_QUICK_ADD = 'quick'
3232

33+
export const ENDPOINT_SYNC = 'sync'
34+
3335
export const ENDPOINT_AUTHORIZATION = 'authorize'
3436
export const ENDPOINT_GET_TOKEN = 'access_token'
3537
export const ENDPOINT_REVOKE_TOKEN = 'access_tokens/revoke'

src/restClient.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,21 @@ describe('restClient', () => {
166166
expect(axiosMock.post).toBeCalledWith(DEFAULT_ENDPOINT, DEFAULT_PAYLOAD)
167167
})
168168

169+
test('post sends expected endpoint and payload to axios when sync commands are used', async () => {
170+
await request(
171+
'POST',
172+
DEFAULT_BASE_URI,
173+
DEFAULT_ENDPOINT,
174+
DEFAULT_AUTH_TOKEN,
175+
DEFAULT_PAYLOAD,
176+
undefined,
177+
true,
178+
)
179+
180+
expect(axiosMock.post).toBeCalledTimes(1)
181+
expect(axiosMock.post).toBeCalledWith(DEFAULT_ENDPOINT, '{"someKey":"someValue"}')
182+
})
183+
169184
test('post returns response from axios', async () => {
170185
const result = await request(
171186
'POST',

src/restClient.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export async function request<T>(
9090
apiToken?: string,
9191
payload?: Record<string, unknown>,
9292
requestId?: string,
93+
hasSyncCommands?: boolean,
9394
): Promise<AxiosResponse<T>> {
9495
// axios loses the original stack when returning errors, for the sake of better reporting
9596
// we capture it here and reapply it to any thrown errors.
@@ -113,7 +114,10 @@ export async function request<T>(
113114
},
114115
})
115116
case 'POST':
116-
return await axiosClient.post<T>(relativePath, payload)
117+
return await axiosClient.post<T>(
118+
relativePath,
119+
hasSyncCommands ? JSON.stringify(payload) : payload,
120+
)
117121
case 'DELETE':
118122
return await axiosClient.delete<T>(relativePath)
119123
}

src/types/requests.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export type QuickAddTaskArgs = {
9595
/**
9696
* @see https://developer.todoist.com/rest/v2/#quick-add-task
9797
*/
98-
export type QuickAddTaskResponse = {
98+
export type SyncTask = {
9999
id: string
100100
projectId: string
101101
content: string
@@ -115,6 +115,20 @@ export type QuickAddTaskResponse = {
115115
deadline: Deadline | null
116116
}
117117

118+
/**
119+
* @see https://developer.todoist.com/rest/v2/#quick-add-task
120+
*/
121+
export type QuickAddTaskResponse = SyncTask
122+
123+
/**
124+
* @see https://developer.todoist.com/sync/v9/#move-an-item
125+
*/
126+
export type MoveTaskArgs = RequireExactlyOne<{
127+
projectId?: string
128+
sectionId?: string
129+
parentId?: string
130+
}>
131+
118132
/**
119133
* @see https://developer.todoist.com/rest/v2/#get-all-projects
120134
*/

src/types/sync.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { SyncTask } from './requests'
2+
3+
export type Command = {
4+
type: string
5+
uuid: string
6+
args: Record<string, unknown>
7+
}
8+
9+
export type SyncError = {
10+
error: string
11+
error_code: number
12+
error_extra: Record<string, unknown>
13+
error_tag: string
14+
http_code: number
15+
}
16+
17+
export type SyncRequest = {
18+
commands: Command[]
19+
resource_types?: string[]
20+
}
21+
22+
export type SyncResponse = {
23+
items?: SyncTask[]
24+
sync_status?: Record<string, 'ok' | SyncError>
25+
}

0 commit comments

Comments
 (0)