diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 765988a7aa0b60..dd884921c2e38c 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -632,4 +632,24 @@ describe('NDV', () => { ndv.actions.changeNodeOperation('Update Row'); ndv.getters.resourceLocatorInput('documentId').find('input').should('have.value', TEST_DOC_ID); }); + + it('Should open appropriate node creator after clicking on connection hint link', () => { + const nodeCreator = new NodeCreator(); + const hintMapper = { + 'Memory': 'AI Nodes', + 'Output Parser': 'AI Nodes', + 'Token Splitter': 'Document Loaders', + 'Tool': 'AI Nodes', + 'Embeddings': 'Vector Stores', + 'Vector Store': 'Retrievers' + } + cy.createFixtureWorkflow('open_node_creator_for_connection.json', `open_node_creator_for_connection ${uuid()}`); + + Object.entries(hintMapper).forEach(([node, group]) => { + workflowPage.actions.openNode(node); + cy.get('[data-action=openSelectiveNodeCreator]').contains('Insert one').click(); + nodeCreator.getters.activeSubcategory().should('contain', group); + cy.realPress('Escape'); + }); + }) }); diff --git a/cypress/fixtures/open_node_creator_for_connection.json b/cypress/fixtures/open_node_creator_for_connection.json new file mode 100644 index 00000000000000..78827d4083fd11 --- /dev/null +++ b/cypress/fixtures/open_node_creator_for_connection.json @@ -0,0 +1,110 @@ +{ + "name": "open_node_creator_for_connection", + "nodes": [ + { + "parameters": {}, + "id": "25ff0c17-7064-4e14-aec6-45c71d63201b", + "name": "When clicking \"Test workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 740, + 520 + ] + }, + { + "parameters": {}, + "id": "49f376ca-845b-4737-aac0-073d0e4fa95c", + "name": "Token Splitter", + "type": "@n8n/n8n-nodes-langchain.textSplitterTokenSplitter", + "typeVersion": 1, + "position": [ + 1180, + 540 + ] + }, + { + "parameters": {}, + "id": "d1db5111-4b01-4620-8ccb-a16ea576c363", + "name": "Memory", + "type": "@n8n/n8n-nodes-langchain.memoryXata", + "typeVersion": 1.2, + "position": [ + 940, + 540 + ], + "credentials": { + "xataApi": { + "id": "q1ckaYlHTWCYDtF0", + "name": "Xata Api account" + } + } + }, + { + "parameters": {}, + "id": "b08b6d3a-bef8-42ac-9cef-ec9d4e5402b1", + "name": "Output Parser", + "type": "@n8n/n8n-nodes-langchain.outputParserStructured", + "typeVersion": 1.1, + "position": [ + 1060, + 540 + ] + }, + { + "parameters": {}, + "id": "ee557938-9cf1-4b78-afef-c783c52fd307", + "name": "Tool", + "type": "@n8n/n8n-nodes-langchain.toolWikipedia", + "typeVersion": 1, + "position": [ + 1300, + 540 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "814f2e9c-cc7b-4f3c-89b4-d6eb82bc24df", + "name": "Embeddings", + "type": "@n8n/n8n-nodes-langchain.embeddingsHuggingFaceInference", + "typeVersion": 1, + "position": [ + 1420, + 540 + ] + }, + { + "parameters": { + "tableName": { + "__rl": true, + "mode": "list", + "value": "" + }, + "options": {} + }, + "id": "e8569b0b-a580-4249-9c5e-f1feed5c644e", + "name": "Vector Store", + "type": "@n8n/n8n-nodes-langchain.vectorStoreSupabase", + "typeVersion": 1, + "position": [ + 1540, + 540 + ] + } + ], + "pinData": {}, + "connections": {}, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "8e90604c-f7e9-489d-8e43-1cc699b7db04", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4" + }, + "id": "L3tgfoW660UOSuX6", + "tags": [] +} \ No newline at end of file diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts index 6356622ad31d68..d80ac6bdeb8a04 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts @@ -171,6 +171,16 @@ export async function execute(this: IExecuteFunctions, i: number): Promise { - executeFunctions.addOutputData(NodeConnectionType.AiMemory, index, [ - [{ json: { action: 'chatHistory', chatHistory: messages } }], - ]); - }) - .catch((error: Error) => { - executeFunctions.addOutputData(NodeConnectionType.AiMemory, index, [ - [{ json: { action: 'chatHistory', error } }], - ]); - }); - return response; + } else if (prop === 'saveContext' && 'saveContext' in target) { + return async (input: InputValues, output: OutputValues): Promise => { + connectionType = NodeConnectionType.AiMemory; + + const { index } = executeFunctions.addInputData(connectionType, [ + [{ json: { action: 'saveContext', input, output } }], + ]); + + const response = (await callMethodAsync.call(target, { + executeFunctions, + connectionType, + currentNodeRunIndex: index, + method: target[prop], + arguments: [input, output], + })) as MemoryVariables; + + const chatHistory = await target.chatHistory.getMessages(); + + executeFunctions.addOutputData(connectionType, index, [ + [{ json: { action: 'saveContext', chatHistory } }], + ]); + + return response; + }; } } diff --git a/packages/@n8n/nodes-langchain/utils/sharedFields.ts b/packages/@n8n/nodes-langchain/utils/sharedFields.ts index f51d4727b5b8c8..ffc9640aafe036 100644 --- a/packages/@n8n/nodes-langchain/utils/sharedFields.ts +++ b/packages/@n8n/nodes-langchain/utils/sharedFields.ts @@ -79,8 +79,15 @@ function determineArticle(nextWord: string): string { const vowels = /^[aeiouAEIOU]/; return vowels.test(nextWord) ? 'an' : 'a'; } +const getConnectionParameterString = (connectionType: string) => { + if (connectionType === '') return "data-action-parameter-creatorview='AI'"; + + return `data-action-parameter-connectiontype='${connectionType}'`; +}; const getAhref = (connectionType: { connection: string; locale: string }) => - `${connectionType.locale}`; + `${connectionType.locale}`; export function getConnectionHintNoticeField( connectionTypes: AllowedConnectionTypes[], @@ -105,12 +112,15 @@ export function getConnectionHintNoticeField( if (groupedConnections.size === 1) { const [[connection, locales]] = Array.from(groupedConnections); + displayName = `This node must be connected to ${determineArticle(locales[0])} ${locales[0] .toLowerCase() .replace( /^ai /, 'AI ', - )}. Insert one`; + )}. Insert one`; } else { const ahrefs = Array.from(groupedConnections, ([connection, locales]) => { // If there are multiple locales, join them with ' or ' diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index de3c512c2b1057..26e336e335f4de 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -1,7 +1,7 @@ import { Service } from 'typedi'; import type { NextFunction, Response } from 'express'; import { createHash } from 'crypto'; -import { JsonWebTokenError, TokenExpiredError, type JwtPayload } from 'jsonwebtoken'; +import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import config from '@/config'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time, inTest } from '@/constants'; @@ -18,16 +18,19 @@ import { UrlService } from '@/services/url.service'; interface AuthJwtPayload { /** User Id */ id: string; - /** User's email */ - email: string | null; - /** SHA-256 hash of bcrypt hash of the user's password */ - password: string | null; + /** This hash is derived from email and bcrypt of password */ + hash: string; } interface IssuedJWT extends AuthJwtPayload { exp: number; } +interface PasswordResetToken { + sub: string; + hash: string; +} + @Service() export class AuthService { constructor( @@ -84,11 +87,9 @@ export class AuthService { } issueJWT(user: User) { - const { id, email, password } = user; const payload: AuthJwtPayload = { - id, - email, - password: password ? this.createPasswordSha(user) : null, + id: user.id, + hash: this.createJWTHash(user), }; return this.jwtService.sign(payload, { expiresIn: this.jwtExpiration, @@ -105,18 +106,13 @@ export class AuthService { where: { id: jwtPayload.id }, }); - // TODO: include these checks in the cache, to avoid computed this over and over again - const passwordHash = user?.password ? this.createPasswordSha(user) : null; - if ( // If not user is found !user || // or, If the user has been deactivated (i.e. LDAP users) user.disabled || - // or, If the password has been updated - jwtPayload.password !== passwordHash || - // or, If the email has been updated - user.email !== jwtPayload.email + // or, If the email or password has been updated + jwtPayload.hash !== this.createJWTHash(user) ) { throw new AuthError('Unauthorized'); } @@ -130,10 +126,8 @@ export class AuthService { } generatePasswordResetToken(user: User, expiresIn = '20m') { - return this.jwtService.sign( - { sub: user.id, passwordSha: this.createPasswordSha(user) }, - { expiresIn }, - ); + const payload: PasswordResetToken = { sub: user.id, hash: this.createJWTHash(user) }; + return this.jwtService.sign(payload, { expiresIn }); } generatePasswordResetUrl(user: User) { @@ -147,7 +141,7 @@ export class AuthService { } async resolvePasswordResetToken(token: string): Promise { - let decodedToken: JwtPayload & { passwordSha: string }; + let decodedToken: PasswordResetToken; try { decodedToken = this.jwtService.verify(token); } catch (e) { @@ -172,7 +166,7 @@ export class AuthService { return; } - if (this.createPasswordSha(user) !== decodedToken.passwordSha) { + if (decodedToken.hash !== this.createJWTHash(user)) { this.logger.debug('Password updated since this token was generated'); return; } @@ -180,10 +174,11 @@ export class AuthService { return user; } - private createPasswordSha({ password }: User) { - return createHash('sha256') - .update(password.slice(password.length / 2)) - .digest('hex'); + createJWTHash({ email, password }: User) { + const hash = createHash('sha256') + .update(email + ':' + password) + .digest('base64'); + return hash.substring(0, 10); } /** How many **milliseconds** before expiration should a JWT be renewed */ diff --git a/packages/cli/test/unit/auth/auth.service.test.ts b/packages/cli/test/unit/auth/auth.service.test.ts index 30674d41eb35d6..9c9e41e061a84d 100644 --- a/packages/cli/test/unit/auth/auth.service.test.ts +++ b/packages/cli/test/unit/auth/auth.service.test.ts @@ -22,7 +22,7 @@ describe('AuthService', () => { mfaEnabled: false, }; const validToken = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInBhc3N3b3JkIjoiMzE1MTNjNWE5ZTNjNWFmZTVjMDZkNTY3NWFjZTc0ZThiYzNmYWRkOTc0NGFiNWQ4OWMzMTFmMmE2MmNjYmQzOSIsImlhdCI6MTcwNjc1MDYyNSwiZXhwIjoxNzA3MzU1NDI1fQ.mtXKUwQDHOhiHn0YNuCeybmxevtNG6LXTAv_sQL63Zc'; + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiaWF0IjoxNzA2NzUwNjI1LCJleHAiOjE3MDczNTU0MjV9.JwY3doH0YrxHdX4nTOlTN4-QMaXsAu5OFOaFcIHSHBI'; const user = mock(userData); const jwtService = new JwtService(mock()); @@ -39,6 +39,20 @@ describe('AuthService', () => { config.set('userManagement.jwtRefreshTimeoutHours', 0); }); + describe('createJWTHash', () => { + it('should generate unique hashes', () => { + expect(authService.createJWTHash(user)).toEqual('mJAYx4Wb7k'); + expect( + authService.createJWTHash(mock({ email: user.email, password: 'newPasswordHash' })), + ).toEqual('FVALtU7AE0'); + expect( + authService.createJWTHash( + mock({ email: 'test1@example.com', password: user.password }), + ), + ).toEqual('y8ha6X01jd'); + }); + }); + describe('authMiddleware', () => { const req = mock({ cookies: {}, user: undefined }); const res = mock(); @@ -200,7 +214,7 @@ describe('AuthService', () => { urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.instance'); const url = authService.generatePasswordResetUrl(user); expect(url).toEqual( - 'https://n8n.instance/change-password?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJwYXNzd29yZFNoYSI6IjMxNTEzYzVhOWUzYzVhZmU1YzA2ZDU2NzVhY2U3NGU4YmMzZmFkZDk3NDRhYjVkODljMzExZjJhNjJjY2JkMzkiLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNjc1MTgyNX0.wsdEpbK2zhFucaPwga7f8EOcwiJcv0iW23HcnvJs-s8&mfaEnabled=false', + 'https://n8n.instance/change-password?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJoYXNoIjoibUpBWXg0V2I3ayIsImlhdCI6MTcwNjc1MDYyNSwiZXhwIjoxNzA2NzUxODI1fQ.rg90I7MKjc_KC77mov59XYAeRc-CoW9ka4mt1dCfrnk&mfaEnabled=false', ); }); }); @@ -216,9 +230,7 @@ describe('AuthService', () => { expect(decoded.sub).toEqual(user.id); expect(decoded.exp - decoded.iat).toEqual(1200); // Expires in 20 minutes - expect(decoded.passwordSha).toEqual( - '31513c5a9e3c5afe5c06d5675ace74e8bc3fadd9744ab5d89c311f2a62ccbd39', - ); + expect(decoded.hash).toEqual('mJAYx4Wb7k'); }); }); diff --git a/packages/editor-ui/src/components/WorkflowLMChat.vue b/packages/editor-ui/src/components/WorkflowLMChat.vue index 7bffa1cae81736..14badd2de527d5 100644 --- a/packages/editor-ui/src/components/WorkflowLMChat.vue +++ b/packages/editor-ui/src/components/WorkflowLMChat.vue @@ -171,6 +171,10 @@ interface LangChainMessage { }; } +interface MemoryOutput { + action: string; + chatHistory?: LangChainMessage[]; +} // TODO: // - display additional information like execution time, tokens used, ... // - display errors better @@ -217,7 +221,10 @@ export default defineComponent({ this.messages = this.getChatMessages(); this.setNode(); - setTimeout(() => this.$refs.inputField?.focus(), 0); + setTimeout(() => { + this.scrollToLatestMessage(); + this.$refs.inputField?.focus(); + }, 0); }, methods: { displayExecution(executionId: string) { @@ -353,32 +360,13 @@ export default defineComponent({ memoryConnection.node, ); - const memoryOutputData = nodeResultData - ?.map( - ( - data, - ): { - action: string; - chatHistory?: unknown[]; - response?: { - sessionId?: unknown[]; - }; - } => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json'])!, + const memoryOutputData = (nodeResultData ?? []) + .map( + (data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput, ) - ?.find((data) => - ['chatHistory', 'loadMemoryVariables'].includes(data?.action) ? data : undefined, - ); - - let chatHistory: LangChainMessage[]; - if (memoryOutputData?.chatHistory) { - chatHistory = memoryOutputData?.chatHistory as LangChainMessage[]; - } else if (memoryOutputData?.response) { - chatHistory = memoryOutputData?.response.sessionId as LangChainMessage[]; - } else { - return []; - } + .find((data) => data.action === 'saveContext'); - return (chatHistory || []).map((message) => { + return (memoryOutputData?.chatHistory ?? []).map((message) => { return { text: message.kwargs.content, sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot', diff --git a/packages/nodes-base/nodes/Notion/NotionTrigger.node.ts b/packages/nodes-base/nodes/Notion/NotionTrigger.node.ts index 5c3576402aed18..ca6c69aa86852f 100644 --- a/packages/nodes-base/nodes/Notion/NotionTrigger.node.ts +++ b/packages/nodes-base/nodes/Notion/NotionTrigger.node.ts @@ -7,9 +7,9 @@ import type { } from 'n8n-workflow'; import moment from 'moment-timezone'; -import { notionApiRequest, simplifyObjects } from './GenericFunctions'; +import { notionApiRequest, simplifyObjects } from './shared/GenericFunctions'; -import { getDatabases } from './SearchFunctions'; +import { listSearch } from './shared/methods'; export class NotionTrigger implements INodeType { description: INodeTypeDescription = { @@ -142,9 +142,7 @@ export class NotionTrigger implements INodeType { }; methods = { - listSearch: { - getDatabases, - }, + listSearch, }; async poll(this: IPollFunctions): Promise { diff --git a/packages/nodes-base/nodes/Notion/GenericFunctions.ts b/packages/nodes-base/nodes/Notion/shared/GenericFunctions.ts similarity index 99% rename from packages/nodes-base/nodes/Notion/GenericFunctions.ts rename to packages/nodes-base/nodes/Notion/shared/GenericFunctions.ts index c20a3b8de814dd..e484970731f58e 100644 --- a/packages/nodes-base/nodes/Notion/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Notion/shared/GenericFunctions.ts @@ -20,7 +20,7 @@ import { camelCase, capitalCase, snakeCase } from 'change-case'; import moment from 'moment-timezone'; import { validate as uuidValidate } from 'uuid'; -import { filters } from './Filters'; +import { filters } from './descriptions/Filters'; function uuidValidateWithoutDashes(this: IExecuteFunctions, value: string) { if (uuidValidate(value)) return true; @@ -151,7 +151,7 @@ export async function notionApiRequestGetBlockChildrens( return responseData; } -export function getBlockTypes() { +export function getBlockTypesOptions() { return [ { name: 'Paragraph', diff --git a/packages/nodes-base/nodes/Notion/BlockDescription.ts b/packages/nodes-base/nodes/Notion/shared/descriptions/BlockDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Notion/BlockDescription.ts rename to packages/nodes-base/nodes/Notion/shared/descriptions/BlockDescription.ts diff --git a/packages/nodes-base/nodes/Notion/Blocks.ts b/packages/nodes-base/nodes/Notion/shared/descriptions/Blocks.ts similarity index 100% rename from packages/nodes-base/nodes/Notion/Blocks.ts rename to packages/nodes-base/nodes/Notion/shared/descriptions/Blocks.ts diff --git a/packages/nodes-base/nodes/Notion/DatabaseDescription.ts b/packages/nodes-base/nodes/Notion/shared/descriptions/DatabaseDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Notion/DatabaseDescription.ts rename to packages/nodes-base/nodes/Notion/shared/descriptions/DatabaseDescription.ts diff --git a/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts b/packages/nodes-base/nodes/Notion/shared/descriptions/DatabasePageDescription.ts similarity index 99% rename from packages/nodes-base/nodes/Notion/DatabasePageDescription.ts rename to packages/nodes-base/nodes/Notion/shared/descriptions/DatabasePageDescription.ts index 6ecf6d7b94037f..4916a3622393ae 100644 --- a/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts +++ b/packages/nodes-base/nodes/Notion/shared/descriptions/DatabasePageDescription.ts @@ -1,6 +1,6 @@ import type { INodeProperties } from 'n8n-workflow'; -import { getConditions, getSearchFilters } from './GenericFunctions'; +import { getConditions, getSearchFilters } from '../GenericFunctions'; import { blocks, text } from './Blocks'; diff --git a/packages/nodes-base/nodes/Notion/Filters.ts b/packages/nodes-base/nodes/Notion/shared/descriptions/Filters.ts similarity index 100% rename from packages/nodes-base/nodes/Notion/Filters.ts rename to packages/nodes-base/nodes/Notion/shared/descriptions/Filters.ts diff --git a/packages/nodes-base/nodes/Notion/PageDescription.ts b/packages/nodes-base/nodes/Notion/shared/descriptions/PageDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Notion/PageDescription.ts rename to packages/nodes-base/nodes/Notion/shared/descriptions/PageDescription.ts diff --git a/packages/nodes-base/nodes/Notion/UserDescription.ts b/packages/nodes-base/nodes/Notion/shared/descriptions/UserDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Notion/UserDescription.ts rename to packages/nodes-base/nodes/Notion/shared/descriptions/UserDescription.ts diff --git a/packages/nodes-base/nodes/Notion/shared/methods/index.ts b/packages/nodes-base/nodes/Notion/shared/methods/index.ts new file mode 100644 index 00000000000000..c7fb720e474f50 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/shared/methods/index.ts @@ -0,0 +1 @@ +export * as listSearch from './listSearch'; diff --git a/packages/nodes-base/nodes/Notion/SearchFunctions.ts b/packages/nodes-base/nodes/Notion/shared/methods/listSearch.ts similarity index 93% rename from packages/nodes-base/nodes/Notion/SearchFunctions.ts rename to packages/nodes-base/nodes/Notion/shared/methods/listSearch.ts index 4501ca23603ffc..1caba20dcff653 100644 --- a/packages/nodes-base/nodes/Notion/SearchFunctions.ts +++ b/packages/nodes-base/nodes/Notion/shared/methods/listSearch.ts @@ -4,7 +4,7 @@ import type { INodeListSearchItems, INodeListSearchResult, } from 'n8n-workflow'; -import { notionApiRequestAllItems } from './GenericFunctions'; +import { notionApiRequestAllItems } from '../GenericFunctions'; export async function getDatabases( this: ILoadOptionsFunctions, diff --git a/packages/nodes-base/nodes/Notion/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Notion/test/GenericFunctions.test.ts index 288aa4e11091cc..dc5baba799c9a7 100644 --- a/packages/nodes-base/nodes/Notion/test/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Notion/test/GenericFunctions.test.ts @@ -1,4 +1,4 @@ -import { formatBlocks } from '../GenericFunctions'; +import { formatBlocks } from '../shared/GenericFunctions'; describe('Test NotionV2, formatBlocks', () => { it('should format to_do block', () => { diff --git a/packages/nodes-base/nodes/Notion/v1/NotionV1.node.ts b/packages/nodes-base/nodes/Notion/v1/NotionV1.node.ts index 2363f2556fe422..587094b91bfbad 100644 --- a/packages/nodes-base/nodes/Notion/v1/NotionV1.node.ts +++ b/packages/nodes-base/nodes/Notion/v1/NotionV1.node.ts @@ -10,23 +10,23 @@ import type { } from 'n8n-workflow'; import moment from 'moment-timezone'; -import type { SortData } from '../GenericFunctions'; +import type { SortData } from '../shared/GenericFunctions'; import { extractDatabaseId, extractDatabaseMentionRLC, extractPageId, formatBlocks, formatTitle, - getBlockTypes, + getBlockTypesOptions, mapFilters, mapProperties, mapSorting, notionApiRequest, notionApiRequestAllItems, simplifyObjects, -} from '../GenericFunctions'; +} from '../shared/GenericFunctions'; -import { getDatabases } from '../SearchFunctions'; +import { listSearch } from '../shared/methods'; import { versionDescription } from './VersionDescription'; export class NotionV1 implements INodeType { @@ -40,9 +40,7 @@ export class NotionV1 implements INodeType { } methods = { - listSearch: { - getDatabases, - }, + listSearch, loadOptions: { async getDatabaseProperties(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; @@ -104,7 +102,7 @@ export class NotionV1 implements INodeType { return returnData; }, async getBlockTypes(this: ILoadOptionsFunctions): Promise { - return getBlockTypes(); + return getBlockTypesOptions(); }, async getPropertySelectValues(this: ILoadOptionsFunctions): Promise { const [name, type] = (this.getCurrentNodeParameter('&key') as string).split('|'); diff --git a/packages/nodes-base/nodes/Notion/v1/VersionDescription.ts b/packages/nodes-base/nodes/Notion/v1/VersionDescription.ts index af7d7c1065c20c..6f08675439a43e 100644 --- a/packages/nodes-base/nodes/Notion/v1/VersionDescription.ts +++ b/packages/nodes-base/nodes/Notion/v1/VersionDescription.ts @@ -1,14 +1,17 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ import type { INodeTypeDescription } from 'n8n-workflow'; -import { databaseFields, databaseOperations } from '../DatabaseDescription'; +import { databaseFields, databaseOperations } from '../shared/descriptions/DatabaseDescription'; -import { userFields, userOperations } from '../UserDescription'; +import { userFields, userOperations } from '../shared/descriptions/UserDescription'; -import { pageFields, pageOperations } from '../PageDescription'; +import { pageFields, pageOperations } from '../shared/descriptions/PageDescription'; -import { blockFields, blockOperations } from '../BlockDescription'; +import { blockFields, blockOperations } from '../shared/descriptions/BlockDescription'; -import { databasePageFields, databasePageOperations } from '../DatabasePageDescription'; +import { + databasePageFields, + databasePageOperations, +} from '../shared/descriptions/DatabasePageDescription'; export const versionDescription: INodeTypeDescription = { displayName: 'Notion', diff --git a/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts b/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts index 2809f8ff9ea9f5..09e67bc4ea65ce 100644 --- a/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts +++ b/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts @@ -1,17 +1,14 @@ import type { IExecuteFunctions, IDataObject, - ILoadOptionsFunctions, INodeExecutionData, - INodePropertyOptions, INodeType, INodeTypeBaseDescription, INodeTypeDescription, } from 'n8n-workflow'; import { jsonParse, NodeApiError } from 'n8n-workflow'; -import moment from 'moment-timezone'; -import type { SortData, FileRecord } from '../GenericFunctions'; +import type { SortData, FileRecord } from '../shared/GenericFunctions'; import { downloadFiles, extractDatabaseId, @@ -19,7 +16,6 @@ import { extractPageId, formatBlocks, formatTitle, - getBlockTypes, mapFilters, mapProperties, mapSorting, @@ -29,9 +25,10 @@ import { simplifyBlocksOutput, simplifyObjects, validateJSON, -} from '../GenericFunctions'; +} from '../shared/GenericFunctions'; -import { getDatabases } from '../SearchFunctions'; +import { listSearch } from '../shared/methods'; +import { loadOptions } from './methods'; import { versionDescription } from './VersionDescription'; export class NotionV2 implements INodeType { @@ -44,290 +41,138 @@ export class NotionV2 implements INodeType { }; } - methods = { - listSearch: { - getDatabases, - }, - loadOptions: { - async getDatabaseProperties(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const databaseId = this.getCurrentNodeParameter('databaseId', { - extractValue: true, - }) as string; - const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); - for (const key of Object.keys(properties as IDataObject)) { - //remove parameters that cannot be set from the API. - if ( - ![ - 'created_time', - 'last_edited_time', - 'created_by', - 'last_edited_by', - 'formula', - 'rollup', - ].includes(properties[key].type as string) - ) { - returnData.push({ - name: `${key}`, - value: `${key}|${properties[key].type}`, - }); - } - } - returnData.sort((a, b) => { - if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) { - return -1; - } - if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) { - return 1; - } - return 0; - }); - return returnData; - }, - async getFilterProperties(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const databaseId = this.getCurrentNodeParameter('databaseId', { - extractValue: true, - }) as string; - const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); - for (const key of Object.keys(properties as IDataObject)) { - returnData.push({ - name: `${key}`, - value: `${key}|${properties[key].type}`, - }); - } - returnData.sort((a, b) => { - if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) { - return -1; - } - if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) { - return 1; - } - return 0; - }); - return returnData; - }, - async getBlockTypes(this: ILoadOptionsFunctions): Promise { - return getBlockTypes(); - }, - async getPropertySelectValues(this: ILoadOptionsFunctions): Promise { - const [name, type] = (this.getCurrentNodeParameter('&key') as string).split('|'); - const databaseId = this.getCurrentNodeParameter('databaseId', { - extractValue: true, - }) as string; - const resource = this.getCurrentNodeParameter('resource') as string; - const operation = this.getCurrentNodeParameter('operation') as string; - const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); - if (resource === 'databasePage') { - if (['multi_select', 'select', 'status'].includes(type) && operation === 'getAll') { - return properties[name][type].options.map((option: IDataObject) => ({ - name: option.name, - value: option.name, - })); - } else if ( - ['multi_select', 'select', 'status'].includes(type) && - ['create', 'update'].includes(operation) - ) { - return properties[name][type].options.map((option: IDataObject) => ({ - name: option.name, - value: option.name, - })); - } - } - return properties[name][type].options.map((option: IDataObject) => ({ - name: option.name, - value: option.id, - })); - }, - async getUsers(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const users = await notionApiRequestAllItems.call(this, 'results', 'GET', '/users'); - for (const user of users) { - if (user.type === 'person') { - returnData.push({ - name: user.name, - value: user.id, - }); - } - } - return returnData; - }, - async getDatabaseIdFromPage(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const pageId = extractPageId( - this.getCurrentNodeParameter('pageId', { extractValue: true }) as string, - ); - const { - parent: { database_id: databaseId }, - } = await notionApiRequest.call(this, 'GET', `/pages/${pageId}`); - const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); - for (const key of Object.keys(properties as IDataObject)) { - //remove parameters that cannot be set from the API. - if ( - ![ - 'created_time', - 'last_edited_time', - 'created_by', - 'last_edited_by', - 'formula', - 'rollup', - ].includes(properties[key].type as string) - ) { - returnData.push({ - name: `${key}`, - value: `${key}|${properties[key].type}`, - }); - } - } - returnData.sort((a, b) => { - if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) { - return -1; - } - if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) { - return 1; - } - return 0; - }); - return returnData; - }, - - async getDatabaseOptionsFromPage( - this: ILoadOptionsFunctions, - ): Promise { - const pageId = extractPageId( - this.getCurrentNodeParameter('pageId', { extractValue: true }) as string, - ); - const [name, type] = (this.getCurrentNodeParameter('&key') as string).split('|'); - const { - parent: { database_id: databaseId }, - } = await notionApiRequest.call(this, 'GET', `/pages/${pageId}`); - const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); - return properties[name][type].options.map((option: IDataObject) => ({ - name: option.name, - value: option.name, - })); - }, - - // Get all the timezones to display them to user so that they can - // select them easily - async getTimezones(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - for (const timezone of moment.tz.names()) { - const timezoneName = timezone; - const timezoneId = timezone; - returnData.push({ - name: timezoneName, - value: timezoneId, - }); - } - returnData.unshift({ - name: 'Default', - value: 'default', - description: 'Timezone set in n8n', - }); - return returnData; - }, - }, - }; + methods = { listSearch, loadOptions }; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); - const returnData: INodeExecutionData[] = []; - const length = items.length; - let responseData; - const qs: IDataObject = {}; - const timezone = this.getTimezone(); - const resource = this.getNodeParameter('resource', 0); const operation = this.getNodeParameter('operation', 0); + + const itemsLength = items.length; + const timezone = this.getTimezone(); + const qs: IDataObject = {}; + + let returnData: INodeExecutionData[] = []; + let responseData; let download = false; if (resource === 'block') { if (operation === 'append') { - for (let i = 0; i < length; i++) { - const blockId = extractPageId( - this.getNodeParameter('blockId', i, '', { extractValue: true }) as string, - ); - const blockValues = this.getNodeParameter('blockUi.blockValues', i, []) as IDataObject[]; - extractDatabaseMentionRLC(blockValues); - const body: IDataObject = { - children: formatBlocks(blockValues), - }; - const block = await notionApiRequest.call( - this, - 'PATCH', - `/blocks/${blockId}/children`, - body, - ); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(block as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } - } - - if (operation === 'getAll') { - for (let i = 0; i < length; i++) { - const blockId = extractPageId( - this.getNodeParameter('blockId', i, '', { extractValue: true }) as string, - ); - const returnAll = this.getNodeParameter('returnAll', i); - const fetchNestedBlocks = this.getNodeParameter('fetchNestedBlocks', i) as boolean; - - if (returnAll) { - responseData = await notionApiRequestAllItems.call( - this, - 'results', - 'GET', - `/blocks/${blockId}/children`, - {}, + for (let i = 0; i < itemsLength; i++) { + try { + const blockId = extractPageId( + this.getNodeParameter('blockId', i, '', { extractValue: true }) as string, ); - - if (fetchNestedBlocks) { - responseData = await notionApiRequestGetBlockChildrens.call(this, responseData); - } - } else { - const limit = this.getNodeParameter('limit', i); - qs.page_size = limit; - responseData = await notionApiRequest.call( + const blockValues = this.getNodeParameter( + 'blockUi.blockValues', + i, + [], + ) as IDataObject[]; + extractDatabaseMentionRLC(blockValues); + const body: IDataObject = { + children: formatBlocks(blockValues), + }; + const block = await notionApiRequest.call( this, - 'GET', + 'PATCH', `/blocks/${blockId}/children`, - {}, - qs, + body, ); - const results = responseData.results; - if (fetchNestedBlocks) { - responseData = await notionApiRequestGetBlockChildrens.call(this, results, [], limit); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(block as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); } else { - responseData = results; + throw error; } } + } + } + + if (operation === 'getAll') { + for (let i = 0; i < itemsLength; i++) { + try { + const blockId = extractPageId( + this.getNodeParameter('blockId', i, '', { extractValue: true }) as string, + ); + const returnAll = this.getNodeParameter('returnAll', i); + const fetchNestedBlocks = this.getNodeParameter('fetchNestedBlocks', i) as boolean; + + if (returnAll) { + responseData = await notionApiRequestAllItems.call( + this, + 'results', + 'GET', + `/blocks/${blockId}/children`, + {}, + ); + + if (fetchNestedBlocks) { + responseData = await notionApiRequestGetBlockChildrens.call(this, responseData); + } + } else { + const limit = this.getNodeParameter('limit', i); + qs.page_size = limit; + responseData = await notionApiRequest.call( + this, + 'GET', + `/blocks/${blockId}/children`, + {}, + qs, + ); + const results = responseData.results; + + if (fetchNestedBlocks) { + responseData = await notionApiRequestGetBlockChildrens.call( + this, + results, + [], + limit, + ); + } else { + responseData = results; + } + } + + responseData = responseData.map((_data: IDataObject) => ({ + object: _data.object, + parent_id: blockId, + ..._data, + })); - responseData = responseData.map((_data: IDataObject) => ({ - object: _data.object, - parent_id: blockId, - ..._data, - })); + const nodeVersion = this.getNode().typeVersion; - const nodeVersion = this.getNode().typeVersion; + if (nodeVersion > 2) { + const simplifyOutput = this.getNodeParameter('simplifyOutput', i) as boolean; - if (nodeVersion > 2) { - const simplifyOutput = this.getNodeParameter('simplifyOutput', i) as boolean; + if (simplifyOutput) { + responseData = simplifyBlocksOutput(responseData, blockId); + } + } - if (simplifyOutput) { - responseData = simplifyBlocksOutput(responseData, blockId); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; } } - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); } } } @@ -335,104 +180,137 @@ export class NotionV2 implements INodeType { if (resource === 'database') { if (operation === 'get') { const simple = this.getNodeParameter('simple', 0) as boolean; - for (let i = 0; i < length; i++) { - const databaseId = extractDatabaseId( - this.getNodeParameter('databaseId', i, '', { extractValue: true }) as string, - ); - responseData = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); - if (simple) { - responseData = simplifyObjects(responseData, download)[0]; - } + for (let i = 0; i < itemsLength; i++) { + try { + const databaseId = extractDatabaseId( + this.getNodeParameter('databaseId', i, '', { extractValue: true }) as string, + ); + responseData = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); + if (simple) { + responseData = simplifyObjects(responseData, download)[0]; + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } } } if (operation === 'getAll') { const simple = this.getNodeParameter('simple', 0) as boolean; - for (let i = 0; i < length; i++) { - const body: IDataObject = { - filter: { property: 'object', value: 'database' }, - }; - const returnAll = this.getNodeParameter('returnAll', i); - if (returnAll) { - responseData = await notionApiRequestAllItems.call( - this, - 'results', - 'POST', - '/search', - body, + for (let i = 0; i < itemsLength; i++) { + try { + const body: IDataObject = { + filter: { property: 'object', value: 'database' }, + }; + const returnAll = this.getNodeParameter('returnAll', i); + if (returnAll) { + responseData = await notionApiRequestAllItems.call( + this, + 'results', + 'POST', + '/search', + body, + ); + } else { + body.page_size = this.getNodeParameter('limit', i); + responseData = await notionApiRequest.call(this, 'POST', '/search', body); + responseData = responseData.results; + } + if (simple) { + responseData = simplifyObjects(responseData, download); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, ); - } else { - body.page_size = this.getNodeParameter('limit', i); - responseData = await notionApiRequest.call(this, 'POST', '/search', body); - responseData = responseData.results; - } - if (simple) { - responseData = simplifyObjects(responseData, download); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } } - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); } } if (operation === 'search') { - for (let i = 0; i < length; i++) { - const text = this.getNodeParameter('text', i) as string; - const options = this.getNodeParameter('options', i); - const returnAll = this.getNodeParameter('returnAll', i); - const simple = this.getNodeParameter('simple', i) as boolean; - const body: IDataObject = { - filter: { - property: 'object', - value: 'database', - }, - }; - - if (text) { - body.query = text; - } - if (options.sort) { - const sort = ((options.sort as IDataObject)?.sortValue as IDataObject) || {}; - body.sort = sort; - } - if (returnAll) { - responseData = await notionApiRequestAllItems.call( - this, - 'results', - 'POST', - '/search', - body, - ); - } else { - qs.limit = this.getNodeParameter('limit', i); - responseData = await notionApiRequestAllItems.call( - this, - 'results', - 'POST', - '/search', - body, - ); - responseData = responseData.splice(0, qs.limit); - } + for (let i = 0; i < itemsLength; i++) { + try { + const text = this.getNodeParameter('text', i) as string; + const options = this.getNodeParameter('options', i); + const returnAll = this.getNodeParameter('returnAll', i); + const simple = this.getNodeParameter('simple', i) as boolean; + const body: IDataObject = { + filter: { + property: 'object', + value: 'database', + }, + }; - if (simple) { - responseData = simplifyObjects(responseData, download); - } + if (text) { + body.query = text; + } + if (options.sort) { + const sort = ((options.sort as IDataObject)?.sortValue as IDataObject) || {}; + body.sort = sort; + } + if (returnAll) { + responseData = await notionApiRequestAllItems.call( + this, + 'results', + 'POST', + '/search', + body, + ); + } else { + qs.limit = this.getNodeParameter('limit', i); + responseData = await notionApiRequestAllItems.call( + this, + 'results', + 'POST', + '/search', + body, + ); + responseData = responseData.splice(0, qs.limit); + } + + if (simple) { + responseData = simplifyObjects(responseData, download); + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } } } } @@ -449,346 +327,453 @@ export class NotionV2 implements INodeType { titleKey = key; } } - for (let i = 0; i < length; i++) { - const title = this.getNodeParameter('title', i) as string; - const simple = this.getNodeParameter('simple', i) as boolean; - - const body: { [key: string]: any } = { - parent: {}, - properties: {}, - }; - if (title !== '') { - body.properties[titleKey] = { - title: [ - { - text: { - content: title, - }, - }, - ], + for (let i = 0; i < itemsLength; i++) { + try { + const title = this.getNodeParameter('title', i) as string; + const simple = this.getNodeParameter('simple', i) as boolean; + + const body: { [key: string]: any } = { + parent: {}, + properties: {}, }; - } - body.parent.database_id = this.getNodeParameter('databaseId', i, '', { - extractValue: true, - }) as string; - const propertiesValues = this.getNodeParameter( - 'propertiesUi.propertyValues', - i, - [], - ) as IDataObject[]; - if (propertiesValues.length !== 0) { - body.properties = Object.assign( - body.properties, - mapProperties.call(this, propertiesValues, timezone, 2) as IDataObject, + if (title !== '') { + body.properties[titleKey] = { + title: [ + { + text: { + content: title, + }, + }, + ], + }; + } + body.parent.database_id = this.getNodeParameter('databaseId', i, '', { + extractValue: true, + }) as string; + const propertiesValues = this.getNodeParameter( + 'propertiesUi.propertyValues', + i, + [], + ) as IDataObject[]; + if (propertiesValues.length !== 0) { + body.properties = Object.assign( + body.properties, + mapProperties.call(this, propertiesValues, timezone, 2) as IDataObject, + ); + } + const blockValues = this.getNodeParameter( + 'blockUi.blockValues', + i, + [], + ) as IDataObject[]; + extractDatabaseMentionRLC(blockValues); + body.children = formatBlocks(blockValues); + + const options = this.getNodeParameter('options', i); + if (options.icon) { + if (options.iconType && options.iconType === 'file') { + body.icon = { external: { url: options.icon } }; + } else { + body.icon = { emoji: options.icon }; + } + } + + responseData = await notionApiRequest.call(this, 'POST', '/pages', body); + if (simple) { + responseData = simplifyObjects(responseData); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, ); - } - const blockValues = this.getNodeParameter('blockUi.blockValues', i, []) as IDataObject[]; - extractDatabaseMentionRLC(blockValues); - body.children = formatBlocks(blockValues); - - const options = this.getNodeParameter('options', i); - if (options.icon) { - if (options.iconType && options.iconType === 'file') { - body.icon = { external: { url: options.icon } }; + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); } else { - body.icon = { emoji: options.icon }; + throw error; } } - - responseData = await notionApiRequest.call(this, 'POST', '/pages', body); - if (simple) { - responseData = simplifyObjects(responseData); - } - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); } } if (operation === 'get') { - for (let i = 0; i < length; i++) { - const pageId = extractPageId( - this.getNodeParameter('pageId', i, '', { extractValue: true }) as string, - ); - const simple = this.getNodeParameter('simple', i) as boolean; - responseData = await notionApiRequest.call(this, 'GET', `/pages/${pageId}`); - if (simple) { - responseData = simplifyObjects(responseData, download); - } + for (let i = 0; i < itemsLength; i++) { + try { + const pageId = extractPageId( + this.getNodeParameter('pageId', i, '', { extractValue: true }) as string, + ); + const simple = this.getNodeParameter('simple', i) as boolean; + responseData = await notionApiRequest.call(this, 'GET', `/pages/${pageId}`); + if (simple) { + responseData = simplifyObjects(responseData, download); + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } } } if (operation === 'getAll') { - for (let i = 0; i < length; i++) { - download = this.getNodeParameter('options.downloadFiles', 0, false) as boolean; - const simple = this.getNodeParameter('simple', 0) as boolean; - const databaseId = this.getNodeParameter('databaseId', i, '', { - extractValue: true, - }) as string; - const returnAll = this.getNodeParameter('returnAll', i); - const filterType = this.getNodeParameter('filterType', 0) as string; - const conditions = this.getNodeParameter('filters.conditions', i, []) as IDataObject[]; - const sort = this.getNodeParameter('options.sort.sortValue', i, []) as IDataObject[]; - const body: IDataObject = { - filter: {}, - }; - - if (filterType === 'manual') { - const matchType = this.getNodeParameter('matchType', 0) as string; - if (matchType === 'anyFilter') { - Object.assign(body.filter!, { - or: conditions.map((data) => mapFilters([data], timezone)), - }); - } else if (matchType === 'allFilters') { - Object.assign(body.filter!, { - and: conditions.map((data) => mapFilters([data], timezone)), - }); + for (let i = 0; i < itemsLength; i++) { + try { + download = this.getNodeParameter('options.downloadFiles', 0, false) as boolean; + const simple = this.getNodeParameter('simple', 0) as boolean; + const databaseId = this.getNodeParameter('databaseId', i, '', { + extractValue: true, + }) as string; + const returnAll = this.getNodeParameter('returnAll', i); + const filterType = this.getNodeParameter('filterType', 0) as string; + const conditions = this.getNodeParameter('filters.conditions', i, []) as IDataObject[]; + const sort = this.getNodeParameter('options.sort.sortValue', i, []) as IDataObject[]; + const body: IDataObject = { + filter: {}, + }; + + if (filterType === 'manual') { + const matchType = this.getNodeParameter('matchType', 0) as string; + if (matchType === 'anyFilter') { + Object.assign(body.filter!, { + or: conditions.map((data) => mapFilters([data], timezone)), + }); + } else if (matchType === 'allFilters') { + Object.assign(body.filter!, { + and: conditions.map((data) => mapFilters([data], timezone)), + }); + } + } else if (filterType === 'json') { + const filterJson = this.getNodeParameter('filterJson', i) as string; + if (validateJSON(filterJson) !== undefined) { + body.filter = jsonParse(filterJson); + } else { + throw new NodeApiError(this.getNode(), { + message: 'Filters (JSON) must be a valid json', + }); + } } - } else if (filterType === 'json') { - const filterJson = this.getNodeParameter('filterJson', i) as string; - if (validateJSON(filterJson) !== undefined) { - body.filter = jsonParse(filterJson); + + if (!Object.keys(body.filter as IDataObject).length) { + delete body.filter; + } + if (sort) { + body.sorts = mapSorting(sort as SortData[]); + } + if (returnAll) { + responseData = await notionApiRequestAllItems.call( + this, + 'results', + 'POST', + `/databases/${databaseId}/query`, + body, + {}, + ); } else { - throw new NodeApiError(this.getNode(), { - message: 'Filters (JSON) must be a valid json', - }); + body.page_size = this.getNodeParameter('limit', i); + responseData = await notionApiRequest.call( + this, + 'POST', + `/databases/${databaseId}/query`, + body, + qs, + ); + responseData = responseData.results; + } + if (download) { + responseData = await downloadFiles.call(this, responseData as FileRecord[], [ + { item: i }, + ]); + } + if (simple) { + responseData = simplifyObjects(responseData, download); } - } - if (!Object.keys(body.filter as IDataObject).length) { - delete body.filter; - } - if (sort) { - body.sorts = mapSorting(sort as SortData[]); - } - if (returnAll) { - responseData = await notionApiRequestAllItems.call( - this, - 'results', - 'POST', - `/databases/${databaseId}/query`, - body, - {}, + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, ); - } else { - body.page_size = this.getNodeParameter('limit', i); - responseData = await notionApiRequest.call( - this, - 'POST', - `/databases/${databaseId}/query`, - body, - qs, - ); - responseData = responseData.results; - } - if (download) { - responseData = await downloadFiles.call(this, responseData as FileRecord[], [ - { item: i }, - ]); - } - if (simple) { - responseData = simplifyObjects(responseData, download); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } } - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); } } if (operation === 'update') { - for (let i = 0; i < length; i++) { - const pageId = extractPageId( - this.getNodeParameter('pageId', i, '', { extractValue: true }) as string, - ); - const simple = this.getNodeParameter('simple', i) as boolean; - const properties = this.getNodeParameter( - 'propertiesUi.propertyValues', - i, - [], - ) as IDataObject[]; - - const body: { [key: string]: any } = { - properties: {}, - }; - if (properties.length !== 0) { - body.properties = mapProperties.call(this, properties, timezone, 2) as IDataObject; - } + for (let i = 0; i < itemsLength; i++) { + try { + const pageId = extractPageId( + this.getNodeParameter('pageId', i, '', { extractValue: true }) as string, + ); + const simple = this.getNodeParameter('simple', i) as boolean; + const properties = this.getNodeParameter( + 'propertiesUi.propertyValues', + i, + [], + ) as IDataObject[]; + + const body: { [key: string]: any } = { + properties: {}, + }; + if (properties.length !== 0) { + body.properties = mapProperties.call(this, properties, timezone, 2) as IDataObject; + } - const options = this.getNodeParameter('options', i); - if (options.icon) { - if (options.iconType && options.iconType === 'file') { - body.icon = { type: 'external', external: { url: options.icon } }; - } else { - body.icon = { type: 'emoji', emoji: options.icon }; + const options = this.getNodeParameter('options', i); + if (options.icon) { + if (options.iconType && options.iconType === 'file') { + body.icon = { type: 'external', external: { url: options.icon } }; + } else { + body.icon = { type: 'emoji', emoji: options.icon }; + } } - } - responseData = await notionApiRequest.call(this, 'PATCH', `/pages/${pageId}`, body); - if (simple) { - responseData = simplifyObjects(responseData, false); - } + responseData = await notionApiRequest.call(this, 'PATCH', `/pages/${pageId}`, body); + if (simple) { + responseData = simplifyObjects(responseData, false); + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } } } } if (resource === 'user') { if (operation === 'get') { - for (let i = 0; i < length; i++) { - const userId = this.getNodeParameter('userId', i) as string; - responseData = await notionApiRequest.call(this, 'GET', `/users/${userId}`); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); + for (let i = 0; i < itemsLength; i++) { + try { + const userId = this.getNodeParameter('userId', i) as string; + responseData = await notionApiRequest.call(this, 'GET', `/users/${userId}`); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } } } if (operation === 'getAll') { - for (let i = 0; i < length; i++) { - const returnAll = this.getNodeParameter('returnAll', i); - if (returnAll) { - responseData = await notionApiRequestAllItems.call(this, 'results', 'GET', '/users'); - } else { - qs.limit = this.getNodeParameter('limit', i); - responseData = await notionApiRequestAllItems.call(this, 'results', 'GET', '/users'); - responseData = responseData.splice(0, qs.limit); - } + for (let i = 0; i < itemsLength; i++) { + try { + const returnAll = this.getNodeParameter('returnAll', i); + if (returnAll) { + responseData = await notionApiRequestAllItems.call(this, 'results', 'GET', '/users'); + } else { + qs.limit = this.getNodeParameter('limit', i); + responseData = await notionApiRequestAllItems.call(this, 'results', 'GET', '/users'); + responseData = responseData.splice(0, qs.limit); + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } } } } if (resource === 'page') { if (operation === 'archive') { - for (let i = 0; i < length; i++) { - const pageId = extractPageId( - this.getNodeParameter('pageId', i, '', { extractValue: true }) as string, - ); - const simple = this.getNodeParameter('simple', i) as boolean; - responseData = await notionApiRequest.call(this, 'PATCH', `/pages/${pageId}`, { - archived: true, - }); - if (simple) { - responseData = simplifyObjects(responseData, download); - } + for (let i = 0; i < itemsLength; i++) { + try { + const pageId = extractPageId( + this.getNodeParameter('pageId', i, '', { extractValue: true }) as string, + ); + const simple = this.getNodeParameter('simple', i) as boolean; + responseData = await notionApiRequest.call(this, 'PATCH', `/pages/${pageId}`, { + archived: true, + }); + if (simple) { + responseData = simplifyObjects(responseData, download); + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } } } if (operation === 'create') { - for (let i = 0; i < length; i++) { - const simple = this.getNodeParameter('simple', i) as boolean; - const body: { [key: string]: any } = { - parent: {}, - properties: {}, - }; - body.parent.page_id = extractPageId( - this.getNodeParameter('pageId', i, '', { extractValue: true }) as string, - ); - body.properties = formatTitle(this.getNodeParameter('title', i) as string); - const blockValues = this.getNodeParameter('blockUi.blockValues', i, []) as IDataObject[]; - extractDatabaseMentionRLC(blockValues); - body.children = formatBlocks(blockValues); - - const options = this.getNodeParameter('options', i); - if (options.icon) { - if (options.iconType && options.iconType === 'file') { - body.icon = { external: { url: options.icon } }; - } else { - body.icon = { emoji: options.icon }; + for (let i = 0; i < itemsLength; i++) { + try { + const simple = this.getNodeParameter('simple', i) as boolean; + const body: { [key: string]: any } = { + parent: {}, + properties: {}, + }; + body.parent.page_id = extractPageId( + this.getNodeParameter('pageId', i, '', { extractValue: true }) as string, + ); + body.properties = formatTitle(this.getNodeParameter('title', i) as string); + const blockValues = this.getNodeParameter( + 'blockUi.blockValues', + i, + [], + ) as IDataObject[]; + extractDatabaseMentionRLC(blockValues); + body.children = formatBlocks(blockValues); + + const options = this.getNodeParameter('options', i); + if (options.icon) { + if (options.iconType && options.iconType === 'file') { + body.icon = { external: { url: options.icon } }; + } else { + body.icon = { emoji: options.icon }; + } } - } - responseData = await notionApiRequest.call(this, 'POST', '/pages', body); - if (simple) { - responseData = simplifyObjects(responseData, download); - } + responseData = await notionApiRequest.call(this, 'POST', '/pages', body); + if (simple) { + responseData = simplifyObjects(responseData, download); + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } } } if (operation === 'search') { - for (let i = 0; i < length; i++) { - const text = this.getNodeParameter('text', i) as string; - const options = this.getNodeParameter('options', i); - const returnAll = this.getNodeParameter('returnAll', i); - const simple = this.getNodeParameter('simple', i) as boolean; - const body: IDataObject = {}; - - if (text) { - body.query = text; - } - if (options.filter) { - const filter = ((options.filter as IDataObject)?.filters as IDataObject[]) || []; - body.filter = filter; - } - if (options.sort) { - const sort = ((options.sort as IDataObject)?.sortValue as IDataObject) || {}; - body.sort = sort; - } - if (returnAll) { - responseData = await notionApiRequestAllItems.call( - this, - 'results', - 'POST', - '/search', - body, - ); - } else { - qs.limit = this.getNodeParameter('limit', i); - responseData = await notionApiRequestAllItems.call( - this, - 'results', - 'POST', - '/search', - body, - ); - responseData = responseData.splice(0, qs.limit); - } + for (let i = 0; i < itemsLength; i++) { + try { + const text = this.getNodeParameter('text', i) as string; + const options = this.getNodeParameter('options', i); + const returnAll = this.getNodeParameter('returnAll', i); + const simple = this.getNodeParameter('simple', i) as boolean; + const body: IDataObject = {}; + + if (text) { + body.query = text; + } + if (options.filter) { + const filter = ((options.filter as IDataObject)?.filters as IDataObject[]) || []; + body.filter = filter; + } + if (options.sort) { + const sort = ((options.sort as IDataObject)?.sortValue as IDataObject) || {}; + body.sort = sort; + } + if (returnAll) { + responseData = await notionApiRequestAllItems.call( + this, + 'results', + 'POST', + '/search', + body, + ); + } else { + qs.limit = this.getNodeParameter('limit', i); + responseData = await notionApiRequestAllItems.call( + this, + 'results', + 'POST', + '/search', + body, + ); + responseData = responseData.splice(0, qs.limit); + } - if (simple) { - responseData = simplifyObjects(responseData, download); - } + if (simple) { + responseData = simplifyObjects(responseData, download); + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject), - { itemData: { item: i } }, - ); - returnData.push(...executionData); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject), + { itemData: { item: i } }, + ); + returnData = returnData.concat(executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + throw error; + } + } } } } diff --git a/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts b/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts index 4917fa9f34a1c5..6e5c42c72b2f78 100644 --- a/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts +++ b/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts @@ -1,14 +1,17 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ import type { INodeTypeDescription } from 'n8n-workflow'; -import { databaseFields, databaseOperations } from '../DatabaseDescription'; +import { databaseFields, databaseOperations } from '../shared/descriptions/DatabaseDescription'; -import { userFields, userOperations } from '../UserDescription'; +import { userFields, userOperations } from '../shared/descriptions/UserDescription'; -import { pageFields, pageOperations } from '../PageDescription'; +import { pageFields, pageOperations } from '../shared/descriptions/PageDescription'; -import { blockFields, blockOperations } from '../BlockDescription'; +import { blockFields, blockOperations } from '../shared/descriptions/BlockDescription'; -import { databasePageFields, databasePageOperations } from '../DatabasePageDescription'; +import { + databasePageFields, + databasePageOperations, +} from '../shared/descriptions/DatabasePageDescription'; export const versionDescription: INodeTypeDescription = { displayName: 'Notion', diff --git a/packages/nodes-base/nodes/Notion/v2/methods/index.ts b/packages/nodes-base/nodes/Notion/v2/methods/index.ts new file mode 100644 index 00000000000000..65ff6192a3afd0 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v2/methods/index.ts @@ -0,0 +1 @@ +export * as loadOptions from './loadOptions'; diff --git a/packages/nodes-base/nodes/Notion/v2/methods/loadOptions.ts b/packages/nodes-base/nodes/Notion/v2/methods/loadOptions.ts new file mode 100644 index 00000000000000..1e0de238361a2a --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v2/methods/loadOptions.ts @@ -0,0 +1,202 @@ +import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; + +import moment from 'moment-timezone'; + +import { + extractPageId, + getBlockTypesOptions, + notionApiRequest, + notionApiRequestAllItems, +} from '../../shared/GenericFunctions'; + +export async function getDatabaseProperties( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodePropertyOptions[] = []; + const databaseId = this.getCurrentNodeParameter('databaseId', { + extractValue: true, + }) as string; + const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); + for (const key of Object.keys(properties as IDataObject)) { + //remove parameters that cannot be set from the API. + if ( + ![ + 'created_time', + 'last_edited_time', + 'created_by', + 'last_edited_by', + 'formula', + 'rollup', + ].includes(properties[key].type as string) + ) { + returnData.push({ + name: `${key}`, + value: `${key}|${properties[key].type}`, + }); + } + } + returnData.sort((a, b) => { + if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) { + return -1; + } + if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) { + return 1; + } + return 0; + }); + return returnData; +} + +export async function getFilterProperties( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodePropertyOptions[] = []; + const databaseId = this.getCurrentNodeParameter('databaseId', { + extractValue: true, + }) as string; + const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); + for (const key of Object.keys(properties as IDataObject)) { + returnData.push({ + name: `${key}`, + value: `${key}|${properties[key].type}`, + }); + } + returnData.sort((a, b) => { + if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) { + return -1; + } + if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) { + return 1; + } + return 0; + }); + return returnData; +} + +export async function getBlockTypes(this: ILoadOptionsFunctions): Promise { + return getBlockTypesOptions(); +} + +export async function getPropertySelectValues( + this: ILoadOptionsFunctions, +): Promise { + const [name, type] = (this.getCurrentNodeParameter('&key') as string).split('|'); + const databaseId = this.getCurrentNodeParameter('databaseId', { + extractValue: true, + }) as string; + const resource = this.getCurrentNodeParameter('resource') as string; + const operation = this.getCurrentNodeParameter('operation') as string; + const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); + if (resource === 'databasePage') { + if (['multi_select', 'select', 'status'].includes(type) && operation === 'getAll') { + return properties[name][type].options.map((option: IDataObject) => ({ + name: option.name, + value: option.name, + })); + } else if ( + ['multi_select', 'select', 'status'].includes(type) && + ['create', 'update'].includes(operation) + ) { + return properties[name][type].options.map((option: IDataObject) => ({ + name: option.name, + value: option.name, + })); + } + } + return properties[name][type].options.map((option: IDataObject) => ({ + name: option.name, + value: option.id, + })); +} + +export async function getUsers(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const users = await notionApiRequestAllItems.call(this, 'results', 'GET', '/users'); + for (const user of users) { + if (user.type === 'person') { + returnData.push({ + name: user.name, + value: user.id, + }); + } + } + return returnData; +} + +export async function getDatabaseIdFromPage( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodePropertyOptions[] = []; + const pageId = extractPageId( + this.getCurrentNodeParameter('pageId', { extractValue: true }) as string, + ); + const { + parent: { database_id: databaseId }, + } = await notionApiRequest.call(this, 'GET', `/pages/${pageId}`); + const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); + for (const key of Object.keys(properties as IDataObject)) { + //remove parameters that cannot be set from the API. + if ( + ![ + 'created_time', + 'last_edited_time', + 'created_by', + 'last_edited_by', + 'formula', + 'rollup', + ].includes(properties[key].type as string) + ) { + returnData.push({ + name: `${key}`, + value: `${key}|${properties[key].type}`, + }); + } + } + returnData.sort((a, b) => { + if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) { + return -1; + } + if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) { + return 1; + } + return 0; + }); + return returnData; +} + +export async function getDatabaseOptionsFromPage( + this: ILoadOptionsFunctions, +): Promise { + const pageId = extractPageId( + this.getCurrentNodeParameter('pageId', { extractValue: true }) as string, + ); + const [name, type] = (this.getCurrentNodeParameter('&key') as string).split('|'); + const { + parent: { database_id: databaseId }, + } = await notionApiRequest.call(this, 'GET', `/pages/${pageId}`); + const { properties } = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); + return properties[name][type].options.map((option: IDataObject) => ({ + name: option.name, + value: option.name, + })); +} + +// Get all the timezones to display them to user so that they can +// select them easily +export async function getTimezones(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + for (const timezone of moment.tz.names()) { + const timezoneName = timezone; + const timezoneId = timezone; + returnData.push({ + name: timezoneName, + value: timezoneId, + }); + } + returnData.unshift({ + name: 'Default', + value: 'default', + description: 'Timezone set in n8n', + }); + return returnData; +}