diff --git a/packages/nodes-base/credentials/TodoistApi.credentials.ts b/packages/nodes-base/credentials/TodoistApi.credentials.ts index 8f5671b43d..a8a8f34410 100644 --- a/packages/nodes-base/credentials/TodoistApi.credentials.ts +++ b/packages/nodes-base/credentials/TodoistApi.credentials.ts @@ -29,7 +29,7 @@ export class TodoistApi implements ICredentialType { test: ICredentialTestRequest = { request: { - baseURL: 'https://api.todoist.com/rest/v1', + baseURL: 'https://api.todoist.com/rest/v2', url: '/labels', }, }; diff --git a/packages/nodes-base/nodes/Todoist/GenericFunctions.ts b/packages/nodes-base/nodes/Todoist/GenericFunctions.ts index abd9353cfb..bd8ff96a0a 100644 --- a/packages/nodes-base/nodes/Todoist/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Todoist/GenericFunctions.ts @@ -23,7 +23,7 @@ export async function todoistApiRequest( ): Promise { const authentication = this.getNodeParameter('authentication', 0) as string; - const endpoint = 'api.todoist.com/rest/v1'; + const endpoint = 'api.todoist.com/rest/v2'; const options: OptionsWithUri = { method, @@ -56,7 +56,7 @@ export async function todoistSyncRequest( headers: {}, method: 'POST', qs, - uri: `https://api.todoist.com/sync/v8/sync`, + uri: `https://api.todoist.com/sync/v9/sync`, json: true, }; diff --git a/packages/nodes-base/nodes/Todoist/Todoist.node.ts b/packages/nodes-base/nodes/Todoist/Todoist.node.ts index 168109a9ba..3ab421c783 100644 --- a/packages/nodes-base/nodes/Todoist/Todoist.node.ts +++ b/packages/nodes-base/nodes/Todoist/Todoist.node.ts @@ -1,688 +1,25 @@ -import { IExecuteFunctions } from 'n8n-core'; +import { INodeTypeBaseDescription, IVersionedNodeType, VersionedNodeType } from 'n8n-workflow'; -import { - IDataObject, - ILoadOptionsFunctions, - INodeExecutionData, - INodePropertyOptions, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; +import { TodoistV1 } from './v1/TodoistV1.node'; +import { TodoistV2 } from './v2/TodoistV2.node'; -import { todoistApiRequest } from './GenericFunctions'; +export class Todoist extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Todoist', + name: 'todoist', + icon: 'file:todoist.svg', + group: ['output'], + defaultVersion: 2, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Todoist API', + }; -import { OperationType, TodoistService } from './Service'; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new TodoistV1(baseDescription), + 2: new TodoistV2(baseDescription), + }; -// interface IBodyCreateTask { -// content?: string; -// description?: string; -// project_id?: number; -// section_id?: number; -// parent_id?: number; -// order?: number; -// label_ids?: number[]; -// priority?: number; -// due_string?: string; -// due_datetime?: string; -// due_date?: string; -// due_lang?: string; -// } - -export class Todoist implements INodeType { - description: INodeTypeDescription = { - displayName: 'Todoist', - name: 'todoist', - icon: 'file:todoist.svg', - group: ['output'], - version: 1, - subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Consume Todoist API', - defaults: { - name: 'Todoist', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - 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', - }, - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Task', - value: 'task', - description: 'Task resource', - }, - ], - default: 'task', - required: true, - }, - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - required: true, - displayOptions: { - show: { - resource: ['task'], - }, - }, - options: [ - { - name: 'Close', - value: 'close', - description: 'Close a task', - action: 'Close a task', - }, - { - name: 'Create', - value: 'create', - description: 'Create a new task', - action: 'Create a task', - }, - { - name: 'Delete', - value: 'delete', - description: 'Delete a task', - action: 'Delete a task', - }, - { - name: 'Get', - value: 'get', - description: 'Get a task', - action: 'Get a task', - }, - { - name: 'Get Many', - value: 'getAll', - description: 'Get many tasks', - action: 'Get many tasks', - }, - { - name: 'Move', - value: 'move', - description: 'Move a task', - action: 'Move a task', - }, - { - name: 'Reopen', - value: 'reopen', - description: 'Reopen a task', - action: 'Reopen a task', - }, - // { - // name: 'Sync', - // value: 'sync', - // description: 'Sync a project', - // }, - { - name: 'Update', - value: 'update', - description: 'Update a task', - action: 'Update a task', - }, - ], - 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', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getProjects', - }, - displayOptions: { - show: { - resource: ['task'], - 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', - type: 'multiOptions', - typeOptions: { - loadOptionsMethod: 'getLabels', - }, - displayOptions: { - show: { - resource: ['task'], - operation: ['create'], - }, - }, - default: [], - description: - 'Optional labels that will be assigned to a created task. Choose from the list, or specify IDs using an expression.', - }, - { - displayName: 'Content', - name: 'content', - type: 'string', - typeOptions: { - rows: 5, - }, - displayOptions: { - show: { - resource: ['task'], - operation: ['create'], - }, - }, - default: '', - required: true, - description: 'Task content', - }, - { - displayName: 'Sync Commands', - name: 'commands', - type: 'string', - displayOptions: { - show: { - resource: ['task'], - operation: ['sync'], - }, - }, - default: '[]', - hint: 'See docs for possible commands: https://developer.todoist.com/sync/v8/#sync', - description: 'Sync body', - }, - { - displayName: 'Additional Fields', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - resource: ['task'], - operation: ['create'], - }, - }, - options: [ - { - displayName: 'Description', - name: 'description', - type: 'string', - default: '', - description: 'A description for the task', - }, - { - displayName: 'Due Date Time', - name: 'dueDateTime', - type: 'dateTime', - default: '', - description: 'Specific date and time in RFC3339 format in UTC', - }, - { - 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: 'Due String', - name: 'dueString', - type: 'string', - default: '', - description: - 'Human defined task due date (ex.: “next Monday”, “Tomorrow”). Value is set using local (not UTC) time.', - }, - { - displayName: 'Parent Name or ID', - name: 'parentId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getItems', - loadOptionsDependsOn: ['project', 'options.section'], - }, - default: {}, - description: - 'The parent task you want to operate on. Choose from the list, or specify an ID using an expression.', - }, - { - displayName: 'Priority', - name: 'priority', - type: 'number', - typeOptions: { - maxValue: 4, - minValue: 1, - }, - default: 1, - description: 'Task priority from 1 (normal) to 4 (urgent)', - }, - { - displayName: 'Section Name or ID', - name: 'section', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getSections', - loadOptionsDependsOn: ['project'], - }, - default: {}, - description: - 'The section you want to operate on. Choose from the list, or specify an ID using an expression.', - }, - ], - }, - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: ['getAll'], - resource: ['task'], - }, - }, - default: false, - description: 'Whether to return all results 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: 50, - description: 'Max number of 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 Name or ID', - name: 'labelId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getLabels', - }, - default: {}, - description: - 'Filter tasks by label. Choose from the list, or specify an ID using an expression.', - }, - { - displayName: 'Lang', - name: 'lang', - type: 'string', - default: '', - description: - 'IETF language tag defining what language filter is written in, if differs from default English', - }, - { - displayName: 'Parent Name or ID', - name: 'parentId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getItems', - loadOptionsDependsOn: ['filters.projectId', 'filters.sectionId'], - }, - default: '', - description: - 'Filter tasks by parent task ID. Choose from the list, or specify an ID using an expression.', - }, - { - displayName: 'Project Name or ID', - name: 'projectId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getProjects', - }, - default: '', - description: - 'Filter tasks by project ID. Choose from the list, or specify an ID using an expression.', - }, - { - displayName: 'Section Name or ID', - name: 'sectionId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getSections', - loadOptionsDependsOn: ['filters.projectId'], - }, - default: '', - description: - 'Filter tasks by section ID. Choose from the list, or specify an ID using an expression.', - }, - ], - }, - { - displayName: 'Update Fields', - name: 'updateFields', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { - show: { - resource: ['task'], - operation: ['update'], - }, - }, - options: [ - { - displayName: 'Content', - name: 'content', - type: 'string', - default: '', - description: 'Task content', - }, - { - displayName: 'Description', - name: 'description', - type: 'string', - default: '', - description: 'A description for the task', - }, - { - displayName: 'Due Date Time', - name: 'dueDateTime', - type: 'dateTime', - default: '', - description: 'Specific date and time in RFC3339 format in UTC', - }, - { - 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: 'Due String', - name: 'dueString', - type: 'string', - 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', - type: 'multiOptions', - description: - 'Choose from the list, or specify IDs using an expression', - typeOptions: { - loadOptionsMethod: 'getLabels', - }, - default: [], - }, - { - displayName: 'Priority', - name: 'priority', - type: 'number', - typeOptions: { - maxValue: 4, - minValue: 1, - }, - default: 1, - description: 'Task priority from 1 (normal) to 4 (urgent)', - }, - ], - }, - ], - }; - - 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[] = []; - const projects = await todoistApiRequest.call(this, 'GET', '/projects'); - for (const project of projects) { - const projectName = project.name; - const projectId = project.id; - - returnData.push({ - name: projectName, - value: projectId, - }); - } - - return returnData; - }, - - // Get all the available sections in the selected project, to display them - // to user so that he can select one easily - async getSections(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - - const options = Object.assign( - {}, - this.getNodeParameter('options', {}), - this.getNodeParameter('filters', {}), - ) as IDataObject; - - const projectId = - (options.projectId as number) ?? (this.getCurrentNodeParameter('project') as number); - if (projectId) { - const qs: IDataObject = { project_id: projectId }; - const sections = await todoistApiRequest.call(this, 'GET', '/sections', {}, qs); - for (const section of sections) { - const sectionName = section.name; - const sectionId = section.id; - - returnData.push({ - name: sectionName, - value: sectionId, - }); - } - } - - return returnData; - }, - - // Get all the available parents in the selected project and section, - // to display them to user so that they can select one easily - async getItems(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - - const options = Object.assign( - {}, - this.getNodeParameter('options', {}), - this.getNodeParameter('filters', {}), - ) as IDataObject; - - const projectId = - (options.projectId as number) ?? (this.getCurrentNodeParameter('project') as number); - - const sectionId = - (options.sectionId as number) || - (options.section as number) || - (this.getCurrentNodeParameter('sectionId') as number); - - if (projectId) { - const qs: IDataObject = sectionId - ? { project_id: projectId, section_id: sectionId } - : { project_id: projectId }; - - const items = await todoistApiRequest.call(this, 'GET', '/tasks', {}, qs); - for (const item of items) { - const itemContent = item.content; - const itemId = item.id; - - returnData.push({ - name: itemContent, - value: itemId, - }); - } - } - - return returnData; - }, - - // Get all the available labels to display them to user so that he can - // select them easily - async getLabels(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const labels = await todoistApiRequest.call(this, 'GET', '/labels'); - - for (const label of labels) { - const labelName = label.name; - const labelId = label.id; - - returnData.push({ - name: labelName, - value: labelId, - }); - } - - return returnData; - }, - }, - }; - - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - const returnData: IDataObject[] = []; - const length = items.length; - 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') { - responseData = await service.execute( - this, - OperationType[operation as keyof typeof OperationType], - i, - ); - } - if (Array.isArray(responseData?.data)) { - returnData.push.apply(returnData, responseData?.data as IDataObject[]); - } else { - if (responseData?.hasOwnProperty('success')) { - returnData.push({ success: responseData.success }); - } else { - returnData.push(responseData?.data as IDataObject); - } - } - } catch (error) { - if (this.continueOnFail()) { - returnData.push({ error: error.message }); - continue; - } - throw error; - } - } - return [this.helpers.returnJsonArray(returnData)]; + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Todoist/v1/OperationHandler.ts b/packages/nodes-base/nodes/Todoist/v1/OperationHandler.ts new file mode 100644 index 0000000000..f76ca49e00 --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/v1/OperationHandler.ts @@ -0,0 +1,354 @@ +import { IDataObject, jsonParse } 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/v2/#create-a-new-task + const content = ctx.getNodeParameter('content', itemIndex) as string; + const projectId = ctx.getNodeParameter('project', itemIndex, undefined, { + extractValue: true, + }) 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.labels = await getLabelNameFromId(ctx, labels); + } + + if (options.section) { + body.section_id = options.section as number; + } + + if (options.dueLang) { + body.due_lang = options.dueLang as string; + } + + if (options.parentId) { + body.parent_id = options.parentId 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; + + 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/v2/#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 = 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/v2/#get-an-active-task + const id = ctx.getNodeParameter('taskId', itemIndex) as string; + + await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}/reopen`); + + return { + success: true, + }; + } +} + +export class UpdateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + //https://developer.todoist.com/rest/v2/#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.labels = await getLabelNameFromId(ctx, 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/v9/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, undefined, { + extractValue: true, + }) as number; + const sections = await getSectionIds(ctx, projectId); + const commands: Command[] = jsonParse(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]) => { + o[key as string] = value; + return o; + }, {} as IDataObject); + } + + 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; + } +} + +async function getLabelNameFromId(ctx: Context, labelIds: number[]): Promise { + const labelList = []; + for (const label of labelIds) { + const thisLabel = await todoistApiRequest.call(ctx, 'GET', `/labels/${label}`); + labelList.push(thisLabel.name); + } + return labelList; +} + +export interface CreateTaskRequest { + content?: string; + description?: string; + project_id?: number; + section_id?: number; + parent_id?: string; + order?: number; + labels?: string[]; + 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/v1/Service.ts similarity index 96% rename from packages/nodes-base/nodes/Todoist/Service.ts rename to packages/nodes-base/nodes/Todoist/v1/Service.ts index 5b248b7415..676439276c 100644 --- a/packages/nodes-base/nodes/Todoist/Service.ts +++ b/packages/nodes-base/nodes/Todoist/v1/Service.ts @@ -10,7 +10,7 @@ import { UpdateHandler, } from './OperationHandler'; -import { Context } from './GenericFunctions'; +import { Context } from '../GenericFunctions'; import { IDataObject } from 'n8n-workflow'; export class TodoistService implements Service { diff --git a/packages/nodes-base/nodes/Todoist/v1/TodoistV1.node.ts b/packages/nodes-base/nodes/Todoist/v1/TodoistV1.node.ts new file mode 100644 index 0000000000..854bcb1b51 --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/v1/TodoistV1.node.ts @@ -0,0 +1,735 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { IExecuteFunctions } from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodeListSearchResult, + INodePropertyOptions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { todoistApiRequest } from '../GenericFunctions'; + +import { OperationType, TodoistService } from './Service'; + +// interface IBodyCreateTask { +// content?: string; +// description?: string; +// project_id?: number; +// section_id?: number; +// parent_id?: number; +// order?: number; +// label_ids?: number[]; +// priority?: number; +// due_string?: string; +// due_datetime?: string; +// due_date?: string; +// due_lang?: string; +// } + +const versionDescription: INodeTypeDescription = { + displayName: 'Todoist', + name: 'todoist', + icon: 'file:todoist.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Todoist API', + defaults: { + name: 'Todoist', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + 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', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Task', + value: 'task', + description: 'Task resource', + }, + ], + default: 'task', + required: true, + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + required: true, + displayOptions: { + show: { + resource: ['task'], + }, + }, + options: [ + { + name: 'Close', + value: 'close', + description: 'Close a task', + action: 'Close a task', + }, + { + name: 'Create', + value: 'create', + description: 'Create a new task', + action: 'Create a task', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a task', + action: 'Delete a task', + }, + { + name: 'Get', + value: 'get', + description: 'Get a task', + action: 'Get a task', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many tasks', + action: 'Get many tasks', + }, + { + name: 'Move', + value: 'move', + description: 'Move a task', + action: 'Move a task', + }, + { + name: 'Reopen', + value: 'reopen', + description: 'Reopen a task', + action: 'Reopen a task', + }, + // { + // name: 'Sync', + // value: 'sync', + // description: 'Sync a project', + // }, + { + name: 'Update', + value: 'update', + description: 'Update a task', + action: 'Update a task', + }, + ], + 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', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'searchProjects', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '2302163813', + }, + ], + displayOptions: { + show: { + resource: ['task'], + operation: ['create', 'move', 'sync'], + }, + }, + description: 'The project you want to operate on. Choose from the list, or specify an ID.', + }, + { + 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', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getLabels', + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + default: [], + description: + 'Optional labels that will be assigned to a created task. Choose from the list, or specify IDs using an expression.', + }, + { + displayName: 'Content', + name: 'content', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + default: '', + required: true, + description: 'Task content', + }, + { + displayName: 'Sync Commands', + name: 'commands', + type: 'string', + displayOptions: { + show: { + resource: ['task'], + operation: ['sync'], + }, + }, + default: '[]', + hint: 'See docs for possible commands: https://developer.todoist.com/sync/v8/#sync', + description: 'Sync body', + }, + { + displayName: 'Additional Fields', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'A description for the task', + }, + { + displayName: 'Due Date Time', + name: 'dueDateTime', + type: 'dateTime', + default: '', + description: 'Specific date and time in RFC3339 format in UTC', + }, + { + 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: 'Due String', + name: 'dueString', + type: 'string', + default: '', + description: + 'Human defined task due date (ex.: “next Monday”, “Tomorrow”). Value is set using local (not UTC) time.', + }, + { + displayName: 'Parent Name or ID', + name: 'parentId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getItems', + loadOptionsDependsOn: ['project', 'options.section'], + }, + default: {}, + description: + 'The parent task you want to operate on. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Priority', + name: 'priority', + type: 'number', + typeOptions: { + maxValue: 4, + minValue: 1, + }, + default: 1, + description: 'Task priority from 1 (normal) to 4 (urgent)', + }, + { + displayName: 'Section Name or ID', + name: 'section', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSections', + loadOptionsDependsOn: ['project'], + }, + default: {}, + description: + 'The section you want to operate on. Choose from the list, or specify an ID using an expression.', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: ['getAll'], + resource: ['task'], + }, + }, + default: false, + description: 'Whether to return all results 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: 50, + description: 'Max number of 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 Name or ID', + name: 'labelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLabels', + }, + default: {}, + description: + 'Filter tasks by label. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Lang', + name: 'lang', + type: 'string', + default: '', + description: + 'IETF language tag defining what language filter is written in, if differs from default English', + }, + { + displayName: 'Parent Name or ID', + name: 'parentId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getItems', + loadOptionsDependsOn: ['filters.projectId', 'filters.sectionId'], + }, + default: '', + description: + 'Filter tasks by parent task ID. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Project Name or ID', + name: 'projectId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + }, + default: '', + description: + 'Filter tasks by project ID. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Section Name or ID', + name: 'sectionId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSections', + loadOptionsDependsOn: ['filters.projectId'], + }, + default: '', + description: + 'Filter tasks by section ID. Choose from the list, or specify an ID using an expression.', + }, + ], + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['task'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Content', + name: 'content', + type: 'string', + default: '', + description: 'Task content', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'A description for the task', + }, + { + displayName: 'Due Date Time', + name: 'dueDateTime', + type: 'dateTime', + default: '', + description: 'Specific date and time in RFC3339 format in UTC', + }, + { + 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: 'Due String', + name: 'dueString', + type: 'string', + 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', + type: 'multiOptions', + description: + 'Choose from the list, or specify IDs using an expression', + typeOptions: { + loadOptionsMethod: 'getLabels', + }, + default: [], + }, + { + displayName: 'Priority', + name: 'priority', + type: 'number', + typeOptions: { + maxValue: 4, + minValue: 1, + }, + default: 1, + description: 'Task priority from 1 (normal) to 4 (urgent)', + }, + ], + }, + ], +}; + +export class TodoistV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + listSearch: { + async searchProjects(this: ILoadOptionsFunctions): Promise { + const projects = await todoistApiRequest.call(this, 'GET', '/projects'); + return { + results: projects.map((project: IDataObject) => ({ + name: project.name, + value: project.id, + })), + }; + }, + async searchLabels(this: ILoadOptionsFunctions): Promise { + const labels = await todoistApiRequest.call(this, 'GET', '/labels'); + return { + results: labels.map((label: IDataObject) => ({ + name: label.name, + value: label.id, + })), + }; + }, + }, + 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[] = []; + const projects = await todoistApiRequest.call(this, 'GET', '/projects'); + for (const project of projects) { + const projectName = project.name; + const projectId = project.id; + + returnData.push({ + name: projectName, + value: projectId, + }); + } + + return returnData; + }, + + // Get all the available sections in the selected project, to display them + // to user so that he can select one easily + async getSections(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + + const options = Object.assign( + {}, + this.getNodeParameter('options', {}), + this.getNodeParameter('filters', {}), + ) as IDataObject; + + const projectId = + (options.projectId as number) ?? + (this.getCurrentNodeParameter('project', { extractValue: true }) as number); + if (projectId) { + const qs: IDataObject = { project_id: projectId }; + const sections = await todoistApiRequest.call(this, 'GET', '/sections', {}, qs); + for (const section of sections) { + const sectionName = section.name; + const sectionId = section.id; + + returnData.push({ + name: sectionName, + value: sectionId, + }); + } + } + + return returnData; + }, + + // Get all the available parents in the selected project and section, + // to display them to user so that they can select one easily + async getItems(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + + const options = Object.assign( + {}, + this.getNodeParameter('options', {}), + this.getNodeParameter('filters', {}), + ) as IDataObject; + + const projectId = + (options.projectId as number) ?? + (this.getCurrentNodeParameter('project', { extractValue: true }) as number); + + const sectionId = + (options.sectionId as number) || + (options.section as number) || + (this.getCurrentNodeParameter('sectionId') as number); + + if (projectId) { + const qs: IDataObject = sectionId + ? { project_id: projectId, section_id: sectionId } + : { project_id: projectId }; + + const items = await todoistApiRequest.call(this, 'GET', '/tasks', {}, qs); + for (const item of items) { + const itemContent = item.content; + const itemId = item.id; + + returnData.push({ + name: itemContent, + value: itemId, + }); + } + } + + return returnData; + }, + + // Get all the available labels to display them to user so that he can + // select them easily + async getLabels(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const labels = await todoistApiRequest.call(this, 'GET', '/labels'); + + for (const label of labels) { + const labelName = label.name; + const labelId = label.id; + returnData.push({ + name: labelName, + value: labelId, + }); + } + + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length; + 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') { + responseData = await service.execute( + this, + OperationType[operation as keyof typeof OperationType], + i, + ); + } + if (Array.isArray(responseData?.data)) { + returnData.push.apply(returnData, responseData?.data as IDataObject[]); + } else { + if (responseData?.hasOwnProperty('success')) { + returnData.push({ success: responseData.success }); + } else { + returnData.push(responseData?.data as IDataObject); + } + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Todoist/OperationHandler.ts b/packages/nodes-base/nodes/Todoist/v2/OperationHandler.ts similarity index 89% rename from packages/nodes-base/nodes/Todoist/OperationHandler.ts rename to packages/nodes-base/nodes/Todoist/v2/OperationHandler.ts index 23998e6f7a..4efe237e99 100644 --- a/packages/nodes-base/nodes/Todoist/OperationHandler.ts +++ b/packages/nodes-base/nodes/Todoist/v2/OperationHandler.ts @@ -4,7 +4,7 @@ import { FormatDueDatetime, todoistApiRequest, todoistSyncRequest, -} from './GenericFunctions'; +} from '../GenericFunctions'; import { Section, TodoistResponse } from './Service'; import { v4 as uuid } from 'uuid'; @@ -14,10 +14,12 @@ export interface OperationHandler { export class CreateHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { - //https://developer.todoist.com/rest/v1/#create-a-new-task + //https://developer.todoist.com/rest/v2/#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 projectId = ctx.getNodeParameter('project', itemIndex, undefined, { + extractValue: true, + }) as number; + const labels = ctx.getNodeParameter('labels', itemIndex) as string[]; const options = ctx.getNodeParameter('options', itemIndex) as IDataObject; const body: CreateTaskRequest = { @@ -39,7 +41,7 @@ export class CreateHandler implements OperationHandler { } if (labels !== undefined && labels.length !== 0) { - body.label_ids = labels; + body.labels = labels; } if (options.section) { @@ -50,6 +52,10 @@ export class CreateHandler implements OperationHandler { body.due_lang = options.dueLang as string; } + if (options.parentId) { + body.parent_id = options.parentId as string; + } + const data = await todoistApiRequest.call(ctx, 'POST', '/tasks', body); return { @@ -95,7 +101,7 @@ export class GetHandler implements OperationHandler { export class GetAllHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { - //https://developer.todoist.com/rest/v1/#get-active-tasks + //https://developer.todoist.com/rest/v2/#get-active-tasks const returnAll = ctx.getNodeParameter('returnAll', itemIndex) as boolean; const filters = ctx.getNodeParameter('filters', itemIndex) as IDataObject; const qs: IDataObject = {}; @@ -104,7 +110,7 @@ export class GetAllHandler implements OperationHandler { qs.project_id = filters.projectId as string; } if (filters.labelId) { - qs.label_id = filters.labelId as string; + qs.label = filters.labelId as string; } if (filters.filter) { qs.filter = filters.filter as string; @@ -142,19 +148,20 @@ async function getSectionIds(ctx: Context, projectId: number): Promise { - //https://developer.todoist.com/rest/v1/#get-an-active-task + //https://developer.todoist.com/rest/v2/#get-an-active-task const id = ctx.getNodeParameter('taskId', itemIndex) as string; - const responseData = await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}/reopen`); + await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}/reopen`); + return { - data: responseData, + success: true, }; } } export class UpdateHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { - //https://developer.todoist.com/rest/v1/#update-a-task + //https://developer.todoist.com/rest/v2/#update-a-task const id = ctx.getNodeParameter('taskId', itemIndex) as string; const updateFields = ctx.getNodeParameter('updateFields', itemIndex) as IDataObject; @@ -185,7 +192,7 @@ export class UpdateHandler implements OperationHandler { Array.isArray(updateFields.labels) && updateFields.labels.length !== 0 ) { - body.label_ids = updateFields.labels as number[]; + body.labels = updateFields.labels as string[]; } if (updateFields.dueLang) { @@ -200,7 +207,7 @@ export class UpdateHandler implements OperationHandler { export class MoveHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { - //https://api.todoist.com/sync/v8/sync + //https://api.todoist.com/sync/v9/sync const taskId = ctx.getNodeParameter('taskId', itemIndex) as number; const section = ctx.getNodeParameter('section', itemIndex) as number; @@ -226,7 +233,9 @@ export class MoveHandler implements OperationHandler { 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 projectId = ctx.getNodeParameter('project', itemIndex, undefined, { + extractValue: true, + }) as number; const sections = await getSectionIds(ctx, projectId); const commands: Command[] = jsonParse(commandsJson); const tempIdMapping = new Map(); @@ -251,10 +260,9 @@ export class SyncHandler implements OperationHandler { private convertToObject(map: Map) { return Array.from(map.entries()).reduce((o, [key, value]) => { - // @ts-ignore o[key] = value; return o; - }, {}); + }, {} as IDataObject); } private enrichUUID(command: Command) { @@ -299,9 +307,9 @@ export interface CreateTaskRequest { description?: string; project_id?: number; section_id?: number; - parent?: number; + parent_id?: string; order?: number; - label_ids?: number[]; + labels?: string[]; priority?: number; due_string?: string; due_datetime?: string; diff --git a/packages/nodes-base/nodes/Todoist/v2/Service.ts b/packages/nodes-base/nodes/Todoist/v2/Service.ts new file mode 100644 index 0000000000..676439276c --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/v2/Service.ts @@ -0,0 +1,62 @@ +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, + itemIndex: number, + ): Promise { + return this.handlers[operation].handleOperation(ctx, itemIndex); + } + + 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, itemIndex: number): Promise; +} + +export interface TodoistResponse { + success?: boolean; + data?: IDataObject; +} diff --git a/packages/nodes-base/nodes/Todoist/v2/TodoistV2.node.ts b/packages/nodes-base/nodes/Todoist/v2/TodoistV2.node.ts new file mode 100644 index 0000000000..b014bb33cd --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/v2/TodoistV2.node.ts @@ -0,0 +1,734 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { IExecuteFunctions } from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodeListSearchResult, + INodePropertyOptions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { todoistApiRequest } from '../GenericFunctions'; + +import { OperationType, TodoistService } from './Service'; + +// interface IBodyCreateTask { +// content?: string; +// description?: string; +// project_id?: number; +// section_id?: number; +// parent_id?: number; +// order?: number; +// label_ids?: number[]; +// priority?: number; +// due_string?: string; +// due_datetime?: string; +// due_date?: string; +// due_lang?: string; +// } + +const versionDescription: INodeTypeDescription = { + displayName: 'Todoist', + name: 'todoist', + icon: 'file:todoist.svg', + group: ['output'], + version: 2, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Todoist API', + defaults: { + name: 'Todoist', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + 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', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Task', + value: 'task', + description: 'Task resource', + }, + ], + default: 'task', + required: true, + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + required: true, + displayOptions: { + show: { + resource: ['task'], + }, + }, + options: [ + { + name: 'Close', + value: 'close', + description: 'Close a task', + action: 'Close a task', + }, + { + name: 'Create', + value: 'create', + description: 'Create a new task', + action: 'Create a task', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a task', + action: 'Delete a task', + }, + { + name: 'Get', + value: 'get', + description: 'Get a task', + action: 'Get a task', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many tasks', + action: 'Get many tasks', + }, + { + name: 'Move', + value: 'move', + description: 'Move a task', + action: 'Move a task', + }, + { + name: 'Reopen', + value: 'reopen', + description: 'Reopen a task', + action: 'Reopen a task', + }, + // { + // name: 'Sync', + // value: 'sync', + // description: 'Sync a project', + // }, + { + name: 'Update', + value: 'update', + description: 'Update a task', + action: 'Update a task', + }, + ], + 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', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'searchProjects', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '2302163813', + }, + ], + displayOptions: { + show: { + resource: ['task'], + operation: ['create', 'move', 'sync'], + }, + }, + description: 'The project you want to operate on. Choose from the list, or specify an ID.', + }, + { + displayName: 'Section Name or ID', + name: 'section', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSections', + loadOptionsDependsOn: ['project.value'], + }, + 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', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getLabels', + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + default: [], + description: + 'Optional labels that will be assigned to a created task. Choose from the list, or specify IDs using an expression.', + }, + { + displayName: 'Content', + name: 'content', + type: 'string', + typeOptions: { + rows: 5, + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + default: '', + required: true, + description: 'Task content', + }, + { + displayName: 'Sync Commands', + name: 'commands', + type: 'string', + displayOptions: { + show: { + resource: ['task'], + operation: ['sync'], + }, + }, + default: '[]', + hint: 'See docs for possible commands: https://developer.todoist.com/sync/v8/#sync', + description: 'Sync body', + }, + { + displayName: 'Additional Fields', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['task'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'A description for the task', + }, + { + displayName: 'Due Date Time', + name: 'dueDateTime', + type: 'dateTime', + default: '', + description: 'Specific date and time in RFC3339 format in UTC', + }, + { + 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: 'Due String', + name: 'dueString', + type: 'string', + default: '', + description: + 'Human defined task due date (ex.: “next Monday”, “Tomorrow”). Value is set using local (not UTC) time.', + }, + { + displayName: 'Parent Name or ID', + name: 'parentId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getItems', + loadOptionsDependsOn: ['project.value', 'options.section'], + }, + default: {}, + description: + 'The parent task you want to operate on. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Priority', + name: 'priority', + type: 'number', + typeOptions: { + maxValue: 4, + minValue: 1, + }, + default: 1, + description: 'Task priority from 1 (normal) to 4 (urgent)', + }, + { + displayName: 'Section Name or ID', + name: 'section', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSections', + loadOptionsDependsOn: ['project.value'], + }, + default: {}, + description: + 'The section you want to operate on. Choose from the list, or specify an ID using an expression.', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: ['getAll'], + resource: ['task'], + }, + }, + default: false, + description: 'Whether to return all results 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: 50, + description: 'Max number of 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 Name or ID', + name: 'labelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLabels', + }, + default: {}, + description: + 'Filter tasks by label. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Lang', + name: 'lang', + type: 'string', + default: '', + description: + 'IETF language tag defining what language filter is written in, if differs from default English', + }, + { + displayName: 'Parent Name or ID', + name: 'parentId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getItems', + loadOptionsDependsOn: ['filters.projectId', 'filters.sectionId'], + }, + default: '', + description: + 'Filter tasks by parent task ID. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Project Name or ID', + name: 'projectId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + }, + default: '', + description: + 'Filter tasks by project ID. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Section Name or ID', + name: 'sectionId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSections', + loadOptionsDependsOn: ['filters.projectId'], + }, + default: '', + description: + 'Filter tasks by section ID. Choose from the list, or specify an ID using an expression.', + }, + ], + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['task'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Content', + name: 'content', + type: 'string', + default: '', + description: 'Task content', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'A description for the task', + }, + { + displayName: 'Due Date Time', + name: 'dueDateTime', + type: 'dateTime', + default: '', + description: 'Specific date and time in RFC3339 format in UTC', + }, + { + 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: 'Due String', + name: 'dueString', + type: 'string', + 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', + type: 'multiOptions', + description: + 'Choose from the list, or specify IDs using an expression', + typeOptions: { + loadOptionsMethod: 'getLabels', + }, + default: [], + }, + { + displayName: 'Priority', + name: 'priority', + type: 'number', + typeOptions: { + maxValue: 4, + minValue: 1, + }, + default: 1, + description: 'Task priority from 1 (normal) to 4 (urgent)', + }, + ], + }, + ], +}; + +export class TodoistV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + listSearch: { + async searchProjects(this: ILoadOptionsFunctions): Promise { + const projects = await todoistApiRequest.call(this, 'GET', '/projects'); + return { + results: projects.map((project: IDataObject) => ({ + name: project.name, + value: project.id, + })), + }; + }, + async searchLabels(this: ILoadOptionsFunctions): Promise { + const labels = await todoistApiRequest.call(this, 'GET', '/labels'); + return { + results: labels.map((label: IDataObject) => ({ + name: label.name, + value: label.name, + })), + }; + }, + }, + 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[] = []; + const projects = await todoistApiRequest.call(this, 'GET', '/projects'); + for (const project of projects) { + const projectName = project.name; + const projectId = project.id; + + returnData.push({ + name: projectName, + value: projectId, + }); + } + + return returnData; + }, + + // Get all the available sections in the selected project, to display them + // to user so that he can select one easily + async getSections(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + + const options = Object.assign( + {}, + this.getNodeParameter('options', {}), + this.getNodeParameter('filters', {}), + ) as IDataObject; + + const projectId = + (options.projectId as number) ?? + (this.getCurrentNodeParameter('project', { extractValue: true }) as number); + if (projectId) { + const qs: IDataObject = { project_id: projectId }; + const sections = await todoistApiRequest.call(this, 'GET', '/sections', {}, qs); + for (const section of sections) { + const sectionName = section.name; + const sectionId = section.id; + + returnData.push({ + name: sectionName, + value: sectionId, + }); + } + } + + return returnData; + }, + + // Get all the available parents in the selected project and section, + // to display them to user so that they can select one easily + async getItems(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + + const options = Object.assign( + {}, + this.getNodeParameter('options', {}), + this.getNodeParameter('filters', {}), + ) as IDataObject; + + const projectId = + (options.projectId as number) ?? + (this.getCurrentNodeParameter('project', { extractValue: true }) as number); + + const sectionId = + (options.sectionId as number) || + (options.section as number) || + (this.getCurrentNodeParameter('sectionId') as number); + + if (projectId) { + const qs: IDataObject = sectionId + ? { project_id: projectId, section_id: sectionId } + : { project_id: projectId }; + + const items = await todoistApiRequest.call(this, 'GET', '/tasks', {}, qs); + for (const item of items) { + const itemContent = item.content; + const itemId = item.id; + + returnData.push({ + name: itemContent, + value: itemId, + }); + } + } + + return returnData; + }, + + // Get all the available labels to display them to user so that he can + // select them easily + async getLabels(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const labels = await todoistApiRequest.call(this, 'GET', '/labels'); + + for (const label of labels) { + const labelName = label.name; + returnData.push({ + name: labelName, + value: labelName, + }); + } + + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length; + 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') { + responseData = await service.execute( + this, + OperationType[operation as keyof typeof OperationType], + i, + ); + } + if (Array.isArray(responseData?.data)) { + returnData.push.apply(returnData, responseData?.data as IDataObject[]); + } else { + if (responseData?.hasOwnProperty('success')) { + returnData.push({ success: responseData.success }); + } else { + returnData.push(responseData?.data as IDataObject); + } + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +}