diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 68be5ce947..c61a00fc5a 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,26 @@ This list shows all the versions which include breaking changes and how to upgrade. +## 0.80.0 + +### What changed? + +We have renamed the operations on the Todoist Node to keep consistency with the codebase. Also, deleted the operations close_match and delete_match as these operations can be accomplished using the operations getAll, close, and delete. + +### When is action necessary? + +When one of the following operations is used. + +- close_by +- close_match +- delete_id +- delete_match + +### How to upgrade: + +After upgrading open all workflows, which contain the Todoist Node, set the corresponding operation, and then save the workflow. + +If the operations close_match or delete_match are used, recreate them using the operations getAll, delete and close. ## 0.69.0 diff --git a/packages/nodes-base/credentials/TodoistOAuth2Api.credentials.ts b/packages/nodes-base/credentials/TodoistOAuth2Api.credentials.ts new file mode 100644 index 0000000000..9341e69c65 --- /dev/null +++ b/packages/nodes-base/credentials/TodoistOAuth2Api.credentials.ts @@ -0,0 +1,46 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class TodoistOAuth2Api implements ICredentialType { + name = 'todoistOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Todoist OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://todoist.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://todoist.com/oauth/access_token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: 'data:read_write,data:delete', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Todoist/GenericFunctions.ts b/packages/nodes-base/nodes/Todoist/GenericFunctions.ts index f4fdfad5e1..d8d7767680 100644 --- a/packages/nodes-base/nodes/Todoist/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Todoist/GenericFunctions.ts @@ -1,66 +1,37 @@ -import { OptionsWithUri } from 'request'; +import { + OptionsWithUri, +} from 'request'; import { IExecuteFunctions, IHookFunctions, ILoadOptionsFunctions, - IExecuteSingleFunctions } from 'n8n-core'; -import * as _ from 'lodash'; - -export const filterAndExecuteForEachTask = async function( - this: IExecuteSingleFunctions, - taskCallback: (t: any) => any -) { - const expression = this.getNodeParameter('expression') as string; - const projectId = this.getNodeParameter('project') as number; - // Enable regular expressions - const reg = new RegExp(expression); - const tasks = await todoistApiRequest.call(this, '/tasks', 'GET'); - const filteredTasks = tasks.filter( - // Make sure that project will match no matter what the type is. If project was not selected match all projects - (el: any) => (!projectId || el.project_id) && el.content.match(reg) - ); - return { - affectedTasks: ( - await Promise.all(filteredTasks.map((t: any) => taskCallback(t))) - ) - // This makes it more clear and informative. We pass the ID as a convention and content to give the user confirmation that his/her expression works as expected - .map( - (el, i) => - el || { id: filteredTasks[i].id, content: filteredTasks[i].content } - ) - }; -}; +import { + IDataObject, +} from 'n8n-workflow'; export async function todoistApiRequest( this: | IHookFunctions | IExecuteFunctions - | IExecuteSingleFunctions | ILoadOptionsFunctions, - resource: string, method: string, + resource: string, body: any = {}, - headers?: object -): Promise { - // tslint:disable-line:no-any - const credentials = this.getCredentials('todoistApi'); - - if (credentials === undefined) { - throw new Error('No credentials got returned!'); - } - - const headerWithAuthentication = Object.assign({}, headers, { Authorization: `Bearer ${credentials.apiKey}` }); + qs: IDataObject = {}, +): Promise { // tslint:disable-line:no-any + const authentication = this.getNodeParameter('authentication', 0, 'apiKey'); const endpoint = 'api.todoist.com/rest/v1'; const options: OptionsWithUri = { - headers: headerWithAuthentication, + headers: {}, method, + qs, uri: `https://${endpoint}${resource}`, - json: true + json: true, }; if (Object.keys(body).length !== 0) { @@ -68,13 +39,25 @@ export async function todoistApiRequest( } try { - return this.helpers.request!(options); + if (authentication === 'apiKey') { + const credentials = this.getCredentials('todoistApi') as IDataObject; + + //@ts-ignore + options.headers['Authorization'] = `Bearer ${credentials.apiKey}`; + + return this.helpers.request!(options); + } else { + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'todoistOAuth2Api', options); + } + } catch (error) { - const errorMessage = error.response.body.message || error.response.body.Message; + const errorMessage = error.response.body; if (errorMessage !== undefined) { - throw errorMessage; + throw new Error(errorMessage); } - throw error.response.body; + + throw errorMessage; } } diff --git a/packages/nodes-base/nodes/Todoist/Todoist.node.ts b/packages/nodes-base/nodes/Todoist/Todoist.node.ts index fc70424c9f..66a8c1120a 100644 --- a/packages/nodes-base/nodes/Todoist/Todoist.node.ts +++ b/packages/nodes-base/nodes/Todoist/Todoist.node.ts @@ -1,6 +1,7 @@ -import { - IExecuteSingleFunctions, +import { + IExecuteFunctions, } from 'n8n-core'; + import { IDataObject, INodeTypeDescription, @@ -9,12 +10,11 @@ import { ILoadOptionsFunctions, INodePropertyOptions, } from 'n8n-workflow'; + import { todoistApiRequest, - filterAndExecuteForEachTask, } from './GenericFunctions'; - interface IBodyCreateTask { content: string; project_id?: number; @@ -48,9 +48,44 @@ export class Todoist implements INodeType { { name: 'todoistApi', required: true, - } + displayOptions: { + show: { + authentication: [ + 'apiKey', + ], + }, + }, + }, + { + name: 'todoistOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'API Key', + value: 'apiKey', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'apiKey', + description: 'The resource to operate on.', + }, { displayName: 'Resource', name: 'resource', @@ -85,24 +120,29 @@ export class Todoist implements INodeType { description: 'Create a new task', }, { - name: 'Close by ID', - value: 'close_id', - description: 'Close a task by passing an ID', + name: 'Close', + value: 'close', + description: 'Close a task', }, { - name: 'Close matching', - value: 'close_match', - description: 'Close a task by passing a regular expression', + name: 'Delete', + value: 'delete', + description: 'Delete a task', }, { - name: 'Delete by ID', - value: 'delete_id', - description: 'Delete a task by passing an ID', + name: 'Get', + value: 'get', + description: 'Get a task', }, { - name: 'Delete matching', - value: 'delete_match', - description: 'Delete a task by passing a regular expression', + name: 'Get All', + value: 'getAll', + description: 'Get all tasks', + }, + { + name: 'Reopen', + value: 'reopen', + description: 'Reopen a task', }, ], default: 'create', @@ -122,9 +162,7 @@ export class Todoist implements INodeType { ], operation: [ 'create', - 'close_match', - 'delete_match', - ] + ], }, }, default: '', @@ -144,7 +182,7 @@ export class Todoist implements INodeType { ], operation: [ 'create', - ] + ], }, }, default: [], @@ -165,7 +203,7 @@ export class Todoist implements INodeType { ], operation: [ 'create', - ] + ], }, }, default: '', @@ -173,32 +211,27 @@ export class Todoist implements INodeType { description: 'Task content', }, { - displayName: 'ID', - name: 'id', + displayName: 'Task ID', + name: 'taskId', type: 'string', default: '', required: true, - typeOptions: { rows: 1 }, displayOptions: { - show: { resource: ['task'], operation: ['close_id', 'delete_id'] } + show: { + resource: [ + 'task', + ], + operation: [ + 'delete', + 'close', + 'get', + 'reopen', + ], + }, }, }, { - displayName: 'Expression to match', - name: 'expression', - type: 'string', - default: '', - required: true, - typeOptions: { rows: 1 }, - displayOptions: { - show: { - resource: ['task'], - operation: ['close_match', 'delete_match'] - } - } - }, - { - displayName: 'Options', + displayName: 'Additional Fields', name: 'options', type: 'collection', placeholder: 'Add Option', @@ -210,22 +243,10 @@ export class Todoist implements INodeType { ], operation: [ 'create', - ] + ], }, }, options: [ - { - displayName: 'Priority', - name: 'priority', - type: 'number', - typeOptions: { - numberStepSize: 1, - maxValue: 4, - minValue: 1, - }, - default: 1, - description: 'Task priority from 1 (normal) to 4 (urgent).', - }, { displayName: 'Due Date Time', name: 'dueDateTime', @@ -240,24 +261,131 @@ 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: 'Priority', + name: 'priority', + type: 'number', + typeOptions: { + numberStepSize: 1, + maxValue: 4, + minValue: 1, + }, + default: 1, + description: 'Task priority from 1 (normal) to 4 (urgent).', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'task', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'task', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Filter', + name: 'filter', + type: 'string', + default: '', + description: 'Filter by any supported filter.', + }, + { + displayName: 'IDs', + name: 'ids', + type: 'string', + default: '', + description: 'A list of the task IDs to retrieve, this should be a comma separated list.', + }, + { + displayName: 'Label ID', + name: 'labelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLabels', + }, + default: {}, + description: 'Filter tasks by label.', + }, + { + displayName: 'Lang', + name: 'lang', + type: 'string', + default: '', + description: 'IETF language tag defining what language filter is written in, if differs from default English', + }, + { + displayName: 'Project ID', + name: 'projectId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + }, + default: '', + description: 'Filter tasks by project id.', + }, + ], + }, + ], }; - methods = { loadOptions: { // Get all the available projects to display them to user so that he can // select them easily async getProjects(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; - let projects; - try { - projects = await todoistApiRequest.call(this, '/projects', 'GET'); - } catch (err) { - throw new Error(`Todoist Error: ${err}`); - } + const projects = await todoistApiRequest.call(this, 'GET', '/projects'); for (const project of projects) { const projectName = project.name; const projectId = project.id; @@ -275,12 +403,8 @@ export class Todoist implements INodeType { // select them easily async getLabels(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; - let labels; - try { - labels = await todoistApiRequest.call(this, '/labels', 'GET'); - } catch (err) { - throw new Error(`Todoist Error: ${err}`); - } + const labels = await todoistApiRequest.call(this, 'GET', '/labels'); + for (const label of labels) { const labelName = label.name; const labelId = label.id; @@ -296,67 +420,111 @@ export class Todoist implements INodeType { } }; - async executeSingle(this: IExecuteSingleFunctions): Promise { - const resource = this.getNodeParameter('resource') as string; - const operation = this.getNodeParameter('operation') as string; - try { - return { - json: { result: await OPERATIONS[resource]?.[operation]?.bind(this)() } - }; - } catch (err) { - return { json: { error: `Todoist Error: ${err.message}` } }; + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length as unknown as number; + const qs: IDataObject = {}; + let responseData; + for (let i = 0; i < length; i++) { + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + 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.dueDateTime) { + body.due_datetime = options.dueDateTime as string; + } + + if (options.dueString) { + body.due_string = options.dueString as string; + } + + if (labels !== undefined && labels.length !== 0) { + body.label_ids = labels; + } + + 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.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 (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } } + + return [this.helpers.returnJsonArray(returnData)]; } } - -const OPERATIONS: { - [key: string]: { [key: string]: (this: IExecuteSingleFunctions) => any }; -} = { - task: { - create(this: IExecuteSingleFunctions) { - //https://developer.todoist.com/rest/v1/#create-a-new-task - const content = this.getNodeParameter('content') as string; - const projectId = this.getNodeParameter('project') as number; - const labels = this.getNodeParameter('labels') as number[]; - const options = this.getNodeParameter('options') as IDataObject; - - const body: IBodyCreateTask = { - content, - project_id: projectId, - priority: (options.priority!) ? parseInt(options.priority as string, 10) : 1, - }; - - if (options.dueDateTime) { - body.due_datetime = options.dueDateTime as string; - } - - if (options.dueString) { - body.due_string = options.dueString as string; - } - - if (labels !== undefined && labels.length !== 0) { - body.label_ids = labels; - } - - return todoistApiRequest.call(this, '/tasks', 'POST', body); - }, - close_id(this: IExecuteSingleFunctions) { - const id = this.getNodeParameter('id') as string; - return todoistApiRequest.call(this, `/tasks/${id}/close`, 'POST'); - }, - delete_id(this: IExecuteSingleFunctions) { - const id = this.getNodeParameter('id') as string; - return todoistApiRequest.call(this, `/tasks/${id}`, 'DELETE'); - }, - close_match(this) { - return filterAndExecuteForEachTask.call(this, t => - todoistApiRequest.call(this, `/tasks/${t.id}/close`, 'POST') - ); - }, - delete_match(this) { - return filterAndExecuteForEachTask.call(this, t => - todoistApiRequest.call(this, `/tasks/${t.id}`, 'DELETE') - ); - } - } -}; diff --git a/packages/nodes-base/nodes/Todoist/todoist.png b/packages/nodes-base/nodes/Todoist/todoist.png index 00af868776..db8c55479d 100644 Binary files a/packages/nodes-base/nodes/Todoist/todoist.png and b/packages/nodes-base/nodes/Todoist/todoist.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index acbba35985..e3e597819c 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -151,6 +151,7 @@ "dist/credentials/SurveyMonkeyOAuth2Api.credentials.js", "dist/credentials/TelegramApi.credentials.js", "dist/credentials/TodoistApi.credentials.js", + "dist/credentials/TodoistOAuth2Api.credentials.js", "dist/credentials/TravisCiApi.credentials.js", "dist/credentials/TrelloApi.credentials.js", "dist/credentials/TwilioApi.credentials.js",