Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
}