diff --git a/src/TodoistApi.ts b/src/TodoistApi.ts index 38ad3d1..e620c9e 100644 --- a/src/TodoistApi.ts +++ b/src/TodoistApi.ts @@ -30,6 +30,7 @@ import { GetSharedLabelsResponse, GetCommentsResponse, QuickAddTaskResponse, + type MoveTaskArgs, } from './types/requests' import { request, isSuccess } from './restClient' import { getTaskFromQuickAddResponse } from './utils/taskConverters' @@ -47,6 +48,7 @@ import { ENDPOINT_REST_LABELS_SHARED, ENDPOINT_REST_LABELS_SHARED_RENAME, ENDPOINT_REST_LABELS_SHARED_REMOVE, + ENDPOINT_SYNC, } from './consts/endpoints' import { validateComment, @@ -63,6 +65,12 @@ import { } from './utils/validators' import { z } from 'zod' +import { v4 as uuidv4 } from 'uuid' +import { SyncResponse, type Command, type SyncRequest } from './types/sync' +import { TodoistRequestError } from './types' + +const MAX_COMMAND_COUNT = 100 + /** * Joins path segments using `/` separator. * @param segments A list of **valid** path segments. @@ -211,6 +219,67 @@ export class TodoistApi { return validateTask(response.data) } + /** + * Moves existing tasks by their ID to either a different parent/section/project. + * + * @param ids - The unique identifier of the tasks to be moved. + * @param args - The paramets that should contain only one of projectId, sectionId, or parentId + * @param requestId - Optional unique identifier for idempotency. + * @returns - A promise that resolves to an array of the updated tasks. + */ + async moveTasks(ids: string[], args: MoveTaskArgs, requestId?: string): Promise { + if (ids.length > MAX_COMMAND_COUNT) { + throw new TodoistRequestError(`Maximum number of items is ${MAX_COMMAND_COUNT}`, 400) + } + const uuid = uuidv4() + const commands: Command[] = ids.map((id) => ({ + type: 'item_move', + uuid, + args: { + id, + ...(args.projectId && { project_id: args.projectId }), + ...(args.sectionId && { section_id: args.sectionId }), + ...(args.parentId && { parent_id: args.parentId }), + }, + })) + + const syncRequest: SyncRequest = { + commands, + resource_types: ['items'], + } + + const response = await request( + 'POST', + this.syncApiBase, + ENDPOINT_SYNC, + this.authToken, + syncRequest, + requestId, + /*hasSyncCommands: */ true, + ) + + if (response.data.sync_status) { + Object.entries(response.data.sync_status).forEach(([_, value]) => { + if (value === 'ok') return + + throw new TodoistRequestError(value.error, value.http_code, value.error_extra) + }) + } + + if (!response.data.items?.length) { + throw new TodoistRequestError('Tasks not found', 404) + } + + const syncTasks = response.data.items.filter((task) => ids.includes(task.id)) + if (!syncTasks.length) { + throw new TodoistRequestError('Tasks not found', 404) + } + + const tasks = syncTasks.map(getTaskFromQuickAddResponse) + + return validateTaskArray(tasks) + } + /** * Closes (completes) a task by its ID. * diff --git a/src/consts/endpoints.ts b/src/consts/endpoints.ts index a038980..0ba8948 100644 --- a/src/consts/endpoints.ts +++ b/src/consts/endpoints.ts @@ -30,6 +30,8 @@ export const ENDPOINT_REST_PROJECT_COLLABORATORS = 'collaborators' export const ENDPOINT_SYNC_QUICK_ADD = 'quick' +export const ENDPOINT_SYNC = 'sync' + export const ENDPOINT_AUTHORIZATION = 'authorize' export const ENDPOINT_GET_TOKEN = 'access_token' export const ENDPOINT_REVOKE_TOKEN = 'access_tokens/revoke' diff --git a/src/restClient.test.ts b/src/restClient.test.ts index eab4515..9129274 100644 --- a/src/restClient.test.ts +++ b/src/restClient.test.ts @@ -166,6 +166,21 @@ describe('restClient', () => { expect(axiosMock.post).toBeCalledWith(DEFAULT_ENDPOINT, DEFAULT_PAYLOAD) }) + test('post sends expected endpoint and payload to axios when sync commands are used', async () => { + await request( + 'POST', + DEFAULT_BASE_URI, + DEFAULT_ENDPOINT, + DEFAULT_AUTH_TOKEN, + DEFAULT_PAYLOAD, + undefined, + true, + ) + + expect(axiosMock.post).toBeCalledTimes(1) + expect(axiosMock.post).toBeCalledWith(DEFAULT_ENDPOINT, '{"someKey":"someValue"}') + }) + test('post returns response from axios', async () => { const result = await request( 'POST', diff --git a/src/restClient.ts b/src/restClient.ts index 55c070b..6b3f859 100644 --- a/src/restClient.ts +++ b/src/restClient.ts @@ -90,6 +90,7 @@ export async function request( apiToken?: string, payload?: Record, requestId?: string, + hasSyncCommands?: boolean, ): Promise> { // axios loses the original stack when returning errors, for the sake of better reporting // we capture it here and reapply it to any thrown errors. @@ -113,7 +114,10 @@ export async function request( }, }) case 'POST': - return await axiosClient.post(relativePath, payload) + return await axiosClient.post( + relativePath, + hasSyncCommands ? JSON.stringify(payload) : payload, + ) case 'DELETE': return await axiosClient.delete(relativePath) } diff --git a/src/types/requests.ts b/src/types/requests.ts index f016bad..9a29b5f 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -95,7 +95,7 @@ export type QuickAddTaskArgs = { /** * @see https://developer.todoist.com/rest/v2/#quick-add-task */ -export type QuickAddTaskResponse = { +export type SyncTask = { id: string projectId: string content: string @@ -115,6 +115,20 @@ export type QuickAddTaskResponse = { deadline: Deadline | null } +/** + * @see https://developer.todoist.com/rest/v2/#quick-add-task + */ +export type QuickAddTaskResponse = SyncTask + +/** + * @see https://developer.todoist.com/sync/v9/#move-an-item + */ +export type MoveTaskArgs = RequireExactlyOne<{ + projectId?: string + sectionId?: string + parentId?: string +}> + /** * @see https://developer.todoist.com/rest/v2/#get-all-projects */ diff --git a/src/types/sync.ts b/src/types/sync.ts new file mode 100644 index 0000000..1f74ef2 --- /dev/null +++ b/src/types/sync.ts @@ -0,0 +1,25 @@ +import type { SyncTask } from './requests' + +export type Command = { + type: string + uuid: string + args: Record +} + +export type SyncError = { + error: string + error_code: number + error_extra: Record + error_tag: string + http_code: number +} + +export type SyncRequest = { + commands: Command[] + resource_types?: string[] +} + +export type SyncResponse = { + items?: SyncTask[] + sync_status?: Record +}