Skip to content

Commit

Permalink
feat: Add moveTasks function (#270)
Browse files Browse the repository at this point in the history
  • Loading branch information
scottlovegrove authored Jan 31, 2025
1 parent 9fed748 commit 517185c
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 2 deletions.
69 changes: 69 additions & 0 deletions src/TodoistApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
GetSharedLabelsResponse,
GetCommentsResponse,
QuickAddTaskResponse,
type MoveTaskArgs,
} from './types/requests'
import { request, isSuccess } from './restClient'
import { getTaskFromQuickAddResponse } from './utils/taskConverters'
Expand All @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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<Task[]> {
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<SyncResponse>(
'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.
*
Expand Down
2 changes: 2 additions & 0 deletions src/consts/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
15 changes: 15 additions & 0 deletions src/restClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion src/restClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export async function request<T>(
apiToken?: string,
payload?: Record<string, unknown>,
requestId?: string,
hasSyncCommands?: boolean,
): Promise<AxiosResponse<T>> {
// 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.
Expand All @@ -113,7 +114,10 @@ export async function request<T>(
},
})
case 'POST':
return await axiosClient.post<T>(relativePath, payload)
return await axiosClient.post<T>(
relativePath,
hasSyncCommands ? JSON.stringify(payload) : payload,
)
case 'DELETE':
return await axiosClient.delete<T>(relativePath)
}
Expand Down
16 changes: 15 additions & 1 deletion src/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
*/
Expand Down
25 changes: 25 additions & 0 deletions src/types/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { SyncTask } from './requests'

export type Command = {
type: string
uuid: string
args: Record<string, unknown>
}

export type SyncError = {
error: string
error_code: number
error_extra: Record<string, unknown>
error_tag: string
http_code: number
}

export type SyncRequest = {
commands: Command[]
resource_types?: string[]
}

export type SyncResponse = {
items?: SyncTask[]
sync_status?: Record<string, 'ok' | SyncError>
}

0 comments on commit 517185c

Please sign in to comment.