diff --git a/packages/nodes-base/credentials/TodoistApi.credentials.ts b/packages/nodes-base/credentials/TodoistApi.credentials.ts index 45b98d6a59..386fc86882 100644 --- a/packages/nodes-base/credentials/TodoistApi.credentials.ts +++ b/packages/nodes-base/credentials/TodoistApi.credentials.ts @@ -4,8 +4,6 @@ import { ICredentialType, INodeProperties, } from 'n8n-workflow'; - - export class TodoistApi implements ICredentialType { name = 'todoistApi'; displayName = 'Todoist API'; @@ -17,6 +15,7 @@ export class TodoistApi implements ICredentialType { type: 'string', default: '', }, + ]; authenticate = { diff --git a/packages/nodes-base/nodes/Todoist/GenericFunctions.ts b/packages/nodes-base/nodes/Todoist/GenericFunctions.ts index 37644ca2ce..a74d8bb6f3 100644 --- a/packages/nodes-base/nodes/Todoist/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Todoist/GenericFunctions.ts @@ -12,6 +12,8 @@ import { IDataObject, NodeApiError, } from 'n8n-workflow'; +export type Context = IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + export function FormatDueDatetime(isoString: string): string { // Assuming that the problem with incorrect date format was caused by milliseconds // Replacing the last 5 characters of ISO-formatted string with just Z char @@ -19,10 +21,7 @@ export function FormatDueDatetime(isoString: string): string { } export async function todoistApiRequest( - this: - | IHookFunctions - | IExecuteFunctions - | ILoadOptionsFunctions, + this: Context, method: string, resource: string, body: any = {}, // tslint:disable-line:no-any @@ -48,6 +47,34 @@ export async function todoistApiRequest( return await this.helpers.requestWithAuthentication.call(this, credentialType, options); } catch (error) { - throw new NodeApiError(this.getNode(), error); + throw new NodeApiError(this.getNode(), (error)); + } +} + +export async function todoistSyncRequest( + this: Context, + body: any = {}, // tslint:disable-line:no-any + qs: IDataObject = {}, +): Promise { // tslint:disable-line:no-any + const authentication = this.getNodeParameter('authentication', 0, 'oAuth2'); + + const options: OptionsWithUri = { + headers: {}, + method: 'POST', + qs, + uri: `https://api.todoist.com/sync/v8/sync`, + json: true, + }; + + if (Object.keys(body).length !== 0) { + options.body = body; + } + + try { + const credentialType = authentication === 'oAuth2' ? 'todoistOAuth2Api' : 'todoistApi'; + return await this.helpers.requestWithAuthentication.call(this, credentialType, options); + + } catch (error) { + throw new NodeApiError(this.getNode(), (error)); } } diff --git a/packages/nodes-base/nodes/Todoist/OperationHandler.ts b/packages/nodes-base/nodes/Todoist/OperationHandler.ts new file mode 100644 index 0000000000..eda191aba7 --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/OperationHandler.ts @@ -0,0 +1,326 @@ +import {IDataObject} from 'n8n-workflow'; +import {Context, FormatDueDatetime, todoistApiRequest, todoistSyncRequest} from './GenericFunctions'; +import {Section, TodoistResponse} from './Service'; +import {v4 as uuid} from 'uuid'; + +export interface OperationHandler { + handleOperation(ctx: Context, itemIndex: number): Promise; +} + +export class CreateHandler implements OperationHandler { + + async handleOperation(ctx: Context, itemIndex: number): Promise { + //https://developer.todoist.com/rest/v1/#create-a-new-task + const content = ctx.getNodeParameter('content', itemIndex) as string; + const projectId = ctx.getNodeParameter('project', itemIndex) as number; + const labels = ctx.getNodeParameter('labels', itemIndex) as number[]; + const options = ctx.getNodeParameter('options', itemIndex) as IDataObject; + + const body: CreateTaskRequest = { + content, + project_id: projectId, + priority: (options.priority!) ? parseInt(options.priority as string, 10) : 1, + }; + + if (options.description) { + body.description = options.description as string; + } + + if (options.dueDateTime) { + body.due_datetime = FormatDueDatetime(options.dueDateTime as string); + } + + if (options.dueString) { + body.due_string = options.dueString as string; + } + + if (labels !== undefined && labels.length !== 0) { + body.label_ids = labels; + } + + if (options.section) { + body.section_id = options.section as number; + } + + if (options.dueLang) { + body.due_lang = options.dueLang as string; + } + + const data = await todoistApiRequest.call(ctx, 'POST', '/tasks', body); + + return { + data, + }; + } + +} + +export class CloseHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('taskId', itemIndex) as string; + + await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}/close`); + + return { + success: true, + }; + } +} + +export class DeleteHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('taskId', itemIndex) as string; + + const responseData = await todoistApiRequest.call(ctx, 'DELETE', `/tasks/${id}`); + + return { + success: true, + }; + } +} + +export class GetHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('taskId', itemIndex) as string; + + const responseData = await todoistApiRequest.call(ctx, 'GET', `/tasks/${id}`); + return { + data: responseData, + }; + } +} + +export class GetAllHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + //https://developer.todoist.com/rest/v1/#get-active-tasks + const returnAll = ctx.getNodeParameter('returnAll', itemIndex) as boolean; + const filters = ctx.getNodeParameter('filters', itemIndex) as IDataObject; + const qs: IDataObject = {}; + + if (filters.projectId) { + qs.project_id = filters.projectId as string; + } + if (filters.labelId) { + qs.label_id = filters.labelId as string; + } + if (filters.filter) { + qs.filter = filters.filter as string; + } + if (filters.lang) { + qs.lang = filters.lang as string; + } + if (filters.ids) { + qs.ids = filters.ids as string; + } + + let responseData = await todoistApiRequest.call(ctx, 'GET', '/tasks', {}, qs); + + if (!returnAll) { + const limit = ctx.getNodeParameter('limit', itemIndex) as number; + responseData = responseData.splice(0, limit); + } + + return { + data: responseData, + }; + } +} + +async function getSectionIds(ctx: Context, projectId: number): Promise> { + const sections: Section[] = await todoistApiRequest.call(ctx, 'GET', '/sections', {}, {project_id: projectId}); + return new Map(sections.map(s => [s.name, s.id as unknown as number])); +} + +export class ReopenHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + //https://developer.todoist.com/rest/v1/#get-an-active-task + const id = ctx.getNodeParameter('taskId', itemIndex) as string; + + const responseData = await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}/reopen`); + return { + data: responseData, + }; + } +} + +export class UpdateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + //https://developer.todoist.com/rest/v1/#update-a-task + const id = ctx.getNodeParameter('taskId', itemIndex) as string; + const updateFields = ctx.getNodeParameter('updateFields', itemIndex) as IDataObject; + + const body: CreateTaskRequest = {}; + + if (updateFields.content) { + body.content = updateFields.content as string; + } + + if (updateFields.priority) { + body.priority = parseInt(updateFields.priority as string, 10); + } + + if (updateFields.description) { + body.description = updateFields.description as string; + } + + if (updateFields.dueDateTime) { + body.due_datetime = FormatDueDatetime(updateFields.dueDateTime as string); + } + + if (updateFields.dueString) { + body.due_string = updateFields.dueString as string; + } + + if (updateFields.labels !== undefined && + Array.isArray(updateFields.labels) && + updateFields.labels.length !== 0) { + body.label_ids = updateFields.labels as number[]; + } + + if (updateFields.dueLang) { + body.due_lang = updateFields.dueLang as string; + } + + await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}`, body); + + return {success: true}; + } +} + +export class MoveHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + //https://api.todoist.com/sync/v8/sync + const taskId = ctx.getNodeParameter('taskId', itemIndex) as number; + const section = ctx.getNodeParameter('section', itemIndex) as number; + + const body: SyncRequest = { + commands: [ + { + type: CommandType.ITEM_MOVE, + uuid: uuid(), + args: { + id: taskId, + section_id: section, + }, + }, + ], + }; + + await todoistSyncRequest.call(ctx, body); + + return {success: true}; + } +} + +export class SyncHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const commandsJson = ctx.getNodeParameter('commands', itemIndex) as string; + const projectId = ctx.getNodeParameter('project', itemIndex) as number; + const sections = await getSectionIds(ctx, projectId); + const commands: Command[] = JSON.parse(commandsJson); + const tempIdMapping = new Map(); + + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + this.enrichUUID(command); + this.enrichSection(command, sections); + this.enrichProjectId(command, projectId); + this.enrichTempId(command,tempIdMapping, projectId); + } + + const body: SyncRequest = { + commands, + temp_id_mapping: this.convertToObject(tempIdMapping), + }; + + await todoistSyncRequest.call(ctx, body); + + return {success: true}; + } + + private convertToObject(map: Map) { + return Array.from(map.entries()).reduce((o, [key, value]) => { + // @ts-ignore + o[key] = value; + return o; + }, {}); + } + + private enrichUUID(command: Command) { + command.uuid = uuid(); + } + + private enrichSection(command: Command, sections: Map) { + if (command.args!==undefined && command.args.section !== undefined) { + const sectionId = sections.get(command.args.section); + if (sectionId) { + command.args.section_id = sectionId; + } else { + throw new Error('Section ' + command.args.section + ' doesn\'t exist on Todoist'); + } + } + } + + private enrichProjectId(command: Command, projectId: number) { + if (this.requiresProjectId(command)) { + command.args.project_id = projectId; + } + } + + private requiresProjectId(command: Command) { + return command.type === CommandType.ITEM_ADD; + } + + private enrichTempId(command: Command, tempIdMapping: Map, projectId: number) { + if (this.requiresTempId(command)) { + command.temp_id = uuid() as string; + tempIdMapping.set(command.temp_id, projectId as unknown as string); + } + } + + private requiresTempId(command: Command) { + return command.type === CommandType.ITEM_ADD; + } +} + +export interface CreateTaskRequest { + content?: string; + description?: string; + project_id?: number; + section_id?: number; + parent?: number; + order?: number; + label_ids?: number[]; + priority?: number; + due_string?: string; + due_datetime?: string; + due_date?: string; + due_lang?: string; +} + +export interface SyncRequest { + commands: Command[]; + temp_id_mapping?: {}; +} + +export interface Command { + type: CommandType; + uuid: string; + temp_id?: string; + args: { + id?: number + section_id?: number + project_id?: number | string + section?: string, + content?: string + }; +} + +export enum CommandType { + ITEM_MOVE = 'item_move', + ITEM_ADD = 'item_add', + ITEM_UPDATE = 'item_update', + ITEM_REORDER = 'item_reorder', + ITEM_DELETE = 'item_delete', + ITEM_COMPLETE = 'item_complete', +} diff --git a/packages/nodes-base/nodes/Todoist/Service.ts b/packages/nodes-base/nodes/Todoist/Service.ts new file mode 100644 index 0000000000..c962b21ff2 --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/Service.ts @@ -0,0 +1,60 @@ +import { + CloseHandler, + CreateHandler, + DeleteHandler, + GetAllHandler, + GetHandler, + MoveHandler, + ReopenHandler, + SyncHandler, + UpdateHandler +} from './OperationHandler'; + +import {Context} from './GenericFunctions'; +import { IDataObject } from 'n8n-workflow'; + +export class TodoistService implements Service { + + async execute(ctx: Context, operation: OperationType): Promise { + return this.handlers[operation].handleOperation(ctx, 0); + } + + private handlers = { + 'create': new CreateHandler(), + 'close': new CloseHandler(), + 'delete': new DeleteHandler(), + 'get': new GetHandler(), + 'getAll': new GetAllHandler(), + 'reopen': new ReopenHandler(), + 'update': new UpdateHandler(), + 'move': new MoveHandler(), + 'sync': new SyncHandler(), + }; + +} + +export enum OperationType { + create = 'create', + close = 'close', + delete = 'delete', + get = 'get', + getAll = 'getAll', + reopen = 'reopen', + update = 'update', + move = 'move', + sync = 'sync', +} + +export interface Section { + name: string; + id: string; +} + +export interface Service { + execute(ctx: Context, operation: OperationType): Promise; +} + +export interface TodoistResponse { + success?: boolean; + data?: IDataObject; +} diff --git a/packages/nodes-base/nodes/Todoist/Todoist.node.ts b/packages/nodes-base/nodes/Todoist/Todoist.node.ts index 382017fc41..72297df03f 100644 --- a/packages/nodes-base/nodes/Todoist/Todoist.node.ts +++ b/packages/nodes-base/nodes/Todoist/Todoist.node.ts @@ -1,6 +1,5 @@ -import { - IExecuteFunctions, -} from 'n8n-core'; +import { response } from 'express'; +import {IExecuteFunctions,} from 'n8n-core'; import { IDataObject, @@ -11,11 +10,9 @@ import { INodeTypeDescription, } from 'n8n-workflow'; -import { - FormatDueDatetime, - todoistApiRequest, -} from './GenericFunctions'; +import {FormatDueDatetime, todoistApiRequest,} from './GenericFunctions'; +import {OperationType, TodoistService} from './Service'; interface IBodyCreateTask { content?: string; description?: string; @@ -141,11 +138,21 @@ export class Todoist implements INodeType { value: 'getAll', description: 'Get all tasks', }, + { + name: 'Move', + value: 'move', + description: 'Move a task', + }, { name: 'Reopen', value: 'reopen', description: 'Reopen a task', }, + // { + // name: 'Sync', + // value: 'sync', + // description: 'Sync a project', + // }, { name: 'Update', value: 'update', @@ -154,6 +161,28 @@ export class Todoist implements INodeType { ], default: 'create', }, + { + displayName: 'Task ID', + name: 'taskId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'delete', + 'close', + 'get', + 'reopen', + 'update', + 'move', + ], + }, + }, + }, { displayName: 'Project Name or ID', name: 'project', @@ -168,12 +197,35 @@ export class Todoist implements INodeType { ], operation: [ 'create', + 'move', + 'sync', ], }, }, default: '', description: 'The project you want to operate on. Choose from the list, or specify an ID using an expression.', }, + { + displayName: 'Section Name or ID', + name: 'section', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSections', + loadOptionsDependsOn: ['project'], + }, + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'move', + ], + }, + }, + default: '', + description: 'Section to which you want move the task. Choose from the list, or specify an ID using an expression.', + }, { displayName: 'Label Names or IDs', name: 'labels', @@ -216,25 +268,22 @@ export class Todoist implements INodeType { description: 'Task content', }, { - displayName: 'Task ID', - name: 'taskId', + displayName: 'Sync Commands', + name: 'commands', type: 'string', - default: '', - required: true, displayOptions: { show: { resource: [ 'task', ], operation: [ - 'delete', - 'close', - 'get', - 'reopen', - 'update', + 'sync', ], }, }, + default: '[]', + hint: 'See docs for possible commands: https://developer.todoist.com/sync/v8/#sync', + description: 'Sync body', }, { displayName: 'Additional Fields', @@ -501,6 +550,13 @@ export class Todoist implements INodeType { default: '', description: 'Human defined task due date (ex.: “next Monday”, “Tomorrow”). Value is set using local (not UTC) time.', }, + { + displayName: 'Due String Locale', + name: 'dueLang', + type: 'string', + default: '', + description: '2-letter code specifying language in case due_string is not written in English', + }, { displayName: 'Label Names or IDs', name: 'labels', @@ -635,169 +691,23 @@ export class Todoist implements INodeType { const items = this.getInputData(); const returnData: IDataObject[] = []; const length = items.length; - const qs: IDataObject = {}; + const service = new TodoistService(); let responseData; - const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; - for (let i = 0; i < length; i++) { - try { if (resource === 'task') { - if (operation === 'create') { - //https://developer.todoist.com/rest/v1/#create-a-new-task - const content = this.getNodeParameter('content', i) as string; - const projectId = this.getNodeParameter('project', i) as number; - const labels = this.getNodeParameter('labels', i) as number[]; - const options = this.getNodeParameter('options', i) as IDataObject; - - const body: IBodyCreateTask = { - content, - project_id: projectId, - priority: (options.priority!) ? parseInt(options.priority as string, 10) : 1, - }; - - if (options.description) { - body.description = options.description as string; - } - - if (options.dueDateTime) { - body.due_datetime = FormatDueDatetime(options.dueDateTime as string); - } - - if (options.dueString) { - body.due_string = options.dueString as string; - } - - if (options.dueLang) { - body.due_lang = options.dueLang as string; - } - - if (labels !== undefined && labels.length !== 0) { - body.label_ids = labels as number[]; - } - - if (options.section) { - body.section_id = options.section as number; - } - - if (options.parentId) { - body.parent_id = options.parentId as number; - } - responseData = await todoistApiRequest.call(this, 'POST', '/tasks', body); - } - if (operation === 'close') { - //https://developer.todoist.com/rest/v1/#close-a-task - const id = this.getNodeParameter('taskId', i) as string; - - responseData = await todoistApiRequest.call(this, 'POST', `/tasks/${id}/close`); - - responseData = { success: true }; - - } - if (operation === 'delete') { - //https://developer.todoist.com/rest/v1/#delete-a-task - const id = this.getNodeParameter('taskId', i) as string; - - responseData = await todoistApiRequest.call(this, 'DELETE', `/tasks/${id}`); - - responseData = { success: true }; - - } - if (operation === 'get') { - //https://developer.todoist.com/rest/v1/#get-an-active-task - const id = this.getNodeParameter('taskId', i) as string; - - responseData = await todoistApiRequest.call(this, 'GET', `/tasks/${id}`); - } - if (operation === 'getAll') { - //https://developer.todoist.com/rest/v1/#get-active-tasks - const returnAll = this.getNodeParameter('returnAll', i) as boolean; - const filters = this.getNodeParameter('filters', i, {}) as IDataObject; - if (filters.projectId) { - qs.project_id = filters.projectId as string; - } - if (filters.sectionId) { - qs.section_id = filters.sectionId as string; - } - if (filters.parentId) { - qs.parent_id = filters.parentId as string; - } - if (filters.labelId) { - qs.label_id = filters.labelId as string; - } - if (filters.filter) { - qs.filter = filters.filter as string; - } - if (filters.lang) { - qs.lang = filters.lang as string; - } - if (filters.ids) { - qs.ids = filters.ids as string; - } - - responseData = await todoistApiRequest.call(this, 'GET', '/tasks', {}, qs); - - if (!returnAll) { - const limit = this.getNodeParameter('limit', i) as number; - responseData = responseData.splice(0, limit); - } - } - if (operation === 'reopen') { - //https://developer.todoist.com/rest/v1/#get-an-active-task - const id = this.getNodeParameter('taskId', i) as string; - - responseData = await todoistApiRequest.call(this, 'POST', `/tasks/${id}/reopen`); - - responseData = { success: true }; - } - - if (operation === 'update') { - //https://developer.todoist.com/rest/v1/#update-a-task - const id = this.getNodeParameter('taskId', i) as string; - const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; - - const body: IBodyCreateTask = {}; - - if (updateFields.content) { - body.content = updateFields.content as string; - } - - if (updateFields.priority) { - body.priority = parseInt(updateFields.priority as string, 10); - } - - if (updateFields.description) { - body.description = updateFields.description as string; - } - - if (updateFields.dueDateTime) { - body.due_datetime = FormatDueDatetime(updateFields.dueDateTime as string); - } - - if (updateFields.dueString) { - body.due_string = updateFields.dueString as string; - } - - if (updateFields.dueLang) { - body.due_lang = updateFields.dueLang as string; - } - - if (updateFields.labels !== undefined && - Array.isArray(updateFields.labels) && - updateFields.labels.length !== 0) { - body.label_ids = updateFields.labels as number[]; - } - - await todoistApiRequest.call(this, 'POST', `/tasks/${id}`, body); - responseData = { success: true }; - } + responseData = (await service.execute(this, OperationType[operation as keyof typeof OperationType])); } - if (Array.isArray(responseData)) { - returnData.push.apply(returnData, responseData as IDataObject[]); + if (Array.isArray(responseData?.data)) { + returnData.push.apply(returnData, responseData?.data as IDataObject[]); } else { - returnData.push(responseData as IDataObject); + if (responseData?.hasOwnProperty('success')) { + returnData.push({ success: responseData.success }); + } else { + returnData.push(responseData?.data as IDataObject); + } } } catch (error) { if (this.continueOnFail()) { @@ -807,7 +717,6 @@ export class Todoist implements INodeType { throw error; } } - return [this.helpers.returnJsonArray(returnData)]; } }