diff --git a/packages/nodes-base/nodes/Notion/Notion.node.ts b/packages/nodes-base/nodes/Notion/Notion.node.ts index 953c4233ef4..00e082f9f0f 100644 --- a/packages/nodes-base/nodes/Notion/Notion.node.ts +++ b/packages/nodes-base/nodes/Notion/Notion.node.ts @@ -3,6 +3,7 @@ import { VersionedNodeType } from 'n8n-workflow'; import { NotionV1 } from './v1/NotionV1.node'; import { NotionV2 } from './v2/NotionV2.node'; +import { NotionV3 } from './v3/NotionV3.node'; export class Notion extends VersionedNodeType { constructor() { @@ -13,7 +14,7 @@ export class Notion extends VersionedNodeType { group: ['output'], subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Consume Notion API', - defaultVersion: 2.2, + defaultVersion: 3, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { @@ -21,6 +22,7 @@ export class Notion extends VersionedNodeType { 2: new NotionV2(baseDescription), 2.1: new NotionV2(baseDescription), 2.2: new NotionV2(baseDescription), + 3: new NotionV3(baseDescription), }; super(nodeVersions, baseDescription); diff --git a/packages/nodes-base/nodes/Notion/NotionTrigger.node.ts b/packages/nodes-base/nodes/Notion/NotionTrigger.node.ts index 4b1eb284196..951d18cfa37 100644 --- a/packages/nodes-base/nodes/Notion/NotionTrigger.node.ts +++ b/packages/nodes-base/nodes/Notion/NotionTrigger.node.ts @@ -15,7 +15,44 @@ import { idValidationRegexp, } from './shared/constants'; import { notionApiRequest, simplifyObjects } from './shared/GenericFunctions'; -import { listSearch } from './shared/methods'; +import { listSearch as legacyListSearch } from './shared/methods'; +import { listSearch as dataSourceListSearch } from './v3/methods'; +import { notionApiRequestV3 } from './v3/transport'; + +type NotionQueryResponse = { + results: IDataObject[]; + has_more?: boolean; + next_cursor?: string | null; +}; + +type NotionQueryRequest = (body: IDataObject) => Promise; + +function createQueryDatabaseRequest(ctx: IPollFunctions): NotionQueryRequest { + const nodeVersion = ctx.getNode().typeVersion; + + if (nodeVersion >= 1.1) { + const dataSourceId = ctx.getNodeParameter('dataSourceId', '', { + extractValue: true, + }) as string; + + return async (body) => + (await notionApiRequestV3.call( + ctx, + 'POST', + `/data_sources/${dataSourceId}/query`, + body, + )) as NotionQueryResponse; + } + + const databaseId = ctx.getNodeParameter('databaseId', '', { extractValue: true }) as string; + + return async (body) => + (await notionApiRequest.call(ctx, 'POST', `/databases/${databaseId}/query`, body, {}, '', { + headers: { + 'Notion-Version': '2022-02-22', + }, + })) as NotionQueryResponse; +} export class NotionTrigger implements INodeType { description: INodeTypeDescription = { @@ -23,7 +60,8 @@ export class NotionTrigger implements INodeType { name: 'notionTrigger', icon: { light: 'file:notion.svg', dark: 'file:notion.dark.svg' }, group: ['trigger'], - version: 1, + version: [1, 1.1], + defaultVersion: 1.1, description: 'Starts the workflow when Notion events occur', subtitle: '={{$parameter["event"]}}', defaults: { @@ -153,11 +191,57 @@ export class NotionTrigger implements INodeType { ], displayOptions: { show: { + '@version': [1], event: ['pageAddedToDatabase', 'pagedUpdatedInDatabase'], }, }, description: 'The Notion Database to operate on', }, + { + displayName: 'Data Source', + name: 'dataSourceId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'Data Source', + name: 'list', + type: 'list', + placeholder: 'Select a Data Source...', + typeOptions: { + searchListMethod: 'getDataSources', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'ab1545b247fb49fa92d6f4b49f4d8116', + validation: [ + { + type: 'regex', + properties: { + regex: idValidationRegexp, + errorMessage: 'Not a valid Notion Data Source ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: idExtractionRegexp, + }, + }, + ], + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.1 } }], + event: ['pageAddedToDatabase', 'pagedUpdatedInDatabase'], + }, + }, + description: 'The Notion Data Source to operate on', + }, { displayName: 'Simplify', name: 'simple', @@ -175,14 +259,17 @@ export class NotionTrigger implements INodeType { }; methods = { - listSearch, + listSearch: { + getDatabases: legacyListSearch.getDatabases, + getDataSources: dataSourceListSearch.getDataSources, + }, }; async poll(this: IPollFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); - const databaseId = this.getNodeParameter('databaseId', '', { extractValue: true }) as string; const event = this.getNodeParameter('event') as string; const simple = this.getNodeParameter('simple') as boolean; + const queryDatabase = createQueryDatabaseRequest(this); const lastTimeChecked = webhookData.lastTimeChecked ? moment(webhookData.lastTimeChecked as string) @@ -196,12 +283,6 @@ export class NotionTrigger implements INodeType { const sortProperty = event === 'pageAddedToDatabase' ? 'created_time' : 'last_edited_time'; - const option: IDataObject = { - headers: { - 'Notion-Version': '2022-02-22', - }, - }; - const body: IDataObject = { page_size: 1, sorts: [ @@ -225,15 +306,7 @@ export class NotionTrigger implements INodeType { let hasMore = true; //get last record - let { results: data } = await notionApiRequest.call( - this, - 'POST', - `/databases/${databaseId}/query`, - body, - {}, - '', - option, - ); + let { results: data } = await queryDatabase(body); if (this.getMode() === 'manual') { if (simple) { @@ -248,18 +321,10 @@ export class NotionTrigger implements INodeType { if (Array.isArray(data) && data.length && Object.keys(data[0] as IDataObject).length !== 0) { do { body.page_size = 10; - const { results, has_more, next_cursor } = await notionApiRequest.call( - this, - 'POST', - `/databases/${databaseId}/query`, - body, - {}, - '', - option, - ); - records.push(...(results as IDataObject[])); - hasMore = has_more; - if (next_cursor !== null) { + const { results, has_more, next_cursor } = await queryDatabase(body); + records.push(...results); + hasMore = has_more ?? false; + if (next_cursor !== undefined && next_cursor !== null) { body.start_cursor = next_cursor; } // Only stop when we reach records strictly before last recorded time to be sure we catch records from the same minute diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/block/append.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/block/append.json new file mode 100644 index 00000000000..0b892baa45e --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/block/append.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "block": { + "type": "object" + }, + "object": { + "type": "string" + }, + "next_cursor": { + "type": ["string", "null"] + }, + "has_more": { + "type": "boolean" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "object": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/block/getAll.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/block/getAll.json new file mode 100644 index 00000000000..7fe71bbdef5 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/block/getAll.json @@ -0,0 +1,52 @@ +{ + "type": "object", + "properties": { + "object": { + "type": "string" + }, + "id": { + "type": "string" + }, + "parent": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "page_id": { + "type": "string" + }, + "block_id": { + "type": "string" + } + } + }, + "last_edited_by": { + "type": "object", + "properties": { + "object": { + "type": "string" + }, + "id": { + "type": "string" + } + } + }, + "has_children": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "content": { + "type": "string" + }, + "root_id": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/block/getMarkdown.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/block/getMarkdown.json new file mode 100644 index 00000000000..f88b1ab1ff2 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/block/getMarkdown.json @@ -0,0 +1,27 @@ +{ + "type": "object", + "properties": { + "object": { + "type": "string" + }, + "id": { + "type": "string" + }, + "markdown": { + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "unknown_block_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "request_id": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/dataSource/get.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/dataSource/get.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/dataSource/get.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/dataSource/search.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/dataSource/search.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/dataSource/search.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/database/get.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/database/get.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/database/get.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/create.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/create.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/create.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/get.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/get.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/get.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/getAll.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/getAll.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/getAll.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/update.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/update.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/databasePage/update.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/archive.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/archive.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/archive.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/create.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/create.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/create.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/getMarkdown.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/getMarkdown.json new file mode 100644 index 00000000000..f88b1ab1ff2 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/getMarkdown.json @@ -0,0 +1,27 @@ +{ + "type": "object", + "properties": { + "object": { + "type": "string" + }, + "id": { + "type": "string" + }, + "markdown": { + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "unknown_block_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "request_id": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/search.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/search.json new file mode 100644 index 00000000000..31715b5779d --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/search.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/updateMarkdown.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/updateMarkdown.json new file mode 100644 index 00000000000..f88b1ab1ff2 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/page/updateMarkdown.json @@ -0,0 +1,27 @@ +{ + "type": "object", + "properties": { + "object": { + "type": "string" + }, + "id": { + "type": "string" + }, + "markdown": { + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "unknown_block_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "request_id": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/user/get.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/user/get.json new file mode 100644 index 00000000000..78fcd51f3df --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/user/get.json @@ -0,0 +1,38 @@ +{ + "type": "object", + "properties": { + "object": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + }, + "avatar_url": { + "type": ["string", "null"] + }, + "type": { + "type": "string" + }, + "person": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + } + } + }, + "bot": { + "type": "object" + }, + "request_id": { + "type": "string" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/user/getAll.json b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/user/getAll.json new file mode 100644 index 00000000000..146b0083386 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/__schema__/v3.0.0/user/getAll.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "properties": { + "object": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + }, + "avatar_url": { + "type": ["string", "null"] + }, + "type": { + "type": "string" + }, + "person": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + } + } + }, + "bot": { + "type": "object" + } + }, + "version": 1 +} diff --git a/packages/nodes-base/nodes/Notion/shared/GenericFunctions.ts b/packages/nodes-base/nodes/Notion/shared/GenericFunctions.ts index 35db8574de5..353a6907021 100644 --- a/packages/nodes-base/nodes/Notion/shared/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Notion/shared/GenericFunctions.ts @@ -18,7 +18,7 @@ import type { IRequestOptions, JsonObject, } from 'n8n-workflow'; -import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError, setSafeObjectProperty } from 'n8n-workflow'; import { validate as uuidValidate } from 'uuid'; import { blockUrlExtractionRegexp, databasePageUrlValidationRegexp } from './constants'; @@ -483,10 +483,14 @@ function getPropertyKeyValue( } function getNameAndType(key: string) { - const [name, type] = key.split('|'); + const delimiterIndex = key.lastIndexOf('|'); + if (delimiterIndex === -1) { + return { name: key, type: '' }; + } + return { - name, - type, + name: key.slice(0, delimiterIndex), + type: key.slice(delimiterIndex + 1), }; } @@ -498,37 +502,25 @@ export function mapProperties( ) { return properties .filter( - (property): property is Record => - typeof property.key === 'string', - ) - .map( - (property) => - [ - `${property.key.split('|')[0]}`, - getPropertyKeyValue.call( - this, - property, - property.key.split('|')[1] as string, - timezone, - version, - ), - ] as const, + (property): property is IDataObject & { key: string } => typeof property.key === 'string', ) + .map((property) => { + const { name, type } = getNameAndType(property.key); + return [name, getPropertyKeyValue.call(this, property, type, timezone, version)] as const; + }) .filter(([, value]) => value) - .reduce( - (obj, [key, value]) => - Object.assign(obj, { - [key]: value, - }), - {}, - ); + .reduce((obj, [key, value]) => { + setSafeObjectProperty(obj, key, value); + return obj; + }, {} as IDataObject); } export function mapSorting(data: SortData[]) { return data.map((sort) => { + const { name } = getNameAndType(sort.key); return { direction: sort.direction, - [sort.timestamp ? 'timestamp' : 'property']: sort.key.split('|')[0], + [sort.timestamp ? 'timestamp' : 'property']: name, }; }); } @@ -677,29 +669,30 @@ export function simplifyObjects(objects: any, download = false, version = 2) { objects = [objects]; } const results: IDataObject[] = []; - for (const { object, id, properties, parent, title, json, binary, url } of objects) { - if (object === 'page' && (parent.type === 'page_id' || parent.type === 'workspace')) { + const notV1 = version > 1; + for (const { object, id, properties, parent, title, name, json, binary, url } of objects) { + if (object === 'page' && (parent?.type === 'page_id' || parent?.type === 'workspace')) { results.push({ id, name: properties.title.title[0].plain_text, - ...(version === 2 ? { url } : {}), + ...(notV1 ? { url } : {}), }); - } else if (object === 'page' && parent.type === 'database_id') { + } else if (object === 'page') { results.push({ id, - ...(version === 2 ? { name: getPropertyTitle(properties as IDataObject) } : {}), - ...(version === 2 ? { url } : {}), - ...(version === 2 + ...(notV1 ? { name: getPropertyTitle(properties as IDataObject) } : {}), + ...(notV1 ? { url } : {}), + ...(notV1 ? { ...prepend('property', simplifyProperties(properties) as IDataObject) } : { ...simplifyProperties(properties) }), } as IDataObject); - } else if (download && json.object === 'page' && json.parent.type === 'database_id') { + } else if (download && json.object === 'page') { results.push({ json: { id: json.id, - ...(version === 2 ? { name: getPropertyTitle(json.properties as IDataObject) } : {}), - ...(version === 2 ? { url: json.url } : {}), - ...(version === 2 + ...(notV1 ? { name: getPropertyTitle(json.properties as IDataObject) } : {}), + ...(notV1 ? { url: json.url } : {}), + ...(notV1 ? { ...prepend('property', simplifyProperties(json.properties) as IDataObject) } : { ...simplifyProperties(json.properties) }), }, @@ -708,10 +701,14 @@ export function simplifyObjects(objects: any, download = false, version = 2) { } else if (object === 'database') { results.push({ id, - ...(version === 2 - ? { name: title[0]?.plain_text || '' } - : { title: title[0]?.plain_text || '' }), - ...(version === 2 ? { url } : {}), + ...(notV1 ? { name: title[0]?.plain_text || '' } : { title: title[0]?.plain_text || '' }), + ...(notV1 ? { url } : {}), + }); + } else if (object === 'data_source') { + results.push({ + id, + name: name ?? title?.[0]?.plain_text ?? '', + url, }); } } @@ -944,16 +941,16 @@ export function getPageId(this: IExecuteFunctions, i: number) { return pageId; } -export function extractDatabaseId(database: string) { - if (database.includes('?v=')) { - const data = database.split('?v=')[0].split('/'); +export function extractResourceId(resource: string): string { + if (resource.includes('?v=')) { + const data = resource.split('?v=')[0].split('/'); const index = data.length - 1; return data[index]; - } else if (database.includes('/')) { - const index = database.split('/').length - 1; - return database.split('/')[index]; + } else if (resource.includes('/')) { + const index = resource.split('/').length - 1; + return resource.split('/')[index]; } else { - return database; + return resource; } } diff --git a/packages/nodes-base/nodes/Notion/shared/descriptions/Blocks.ts b/packages/nodes-base/nodes/Notion/shared/descriptions/Blocks.ts index 31f4941e16c..3d138bbcd70 100644 --- a/packages/nodes-base/nodes/Notion/shared/descriptions/Blocks.ts +++ b/packages/nodes-base/nodes/Notion/shared/descriptions/Blocks.ts @@ -142,183 +142,213 @@ const annotation: INodeProperties[] = [ }, ]; -const typeMention: INodeProperties[] = [ - { - displayName: 'Type', - name: 'mentionType', - type: 'options', - displayOptions: { - show: { - textType: ['mention'], - }, - }, - options: [ - { - name: 'Database', - value: 'database', - }, - { - name: 'Date', - value: 'date', - }, - { - name: 'Page', - value: 'page', - }, - { - name: 'User', - value: 'user', - }, - ], - default: '', - description: - 'An inline mention of a user, page, database, or date. In the app these are created by typing @ followed by the name of a user, page, database, or a date.', - }, - { - displayName: 'User Name or ID', - name: 'user', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - displayOptions: { - show: { - mentionType: ['user'], - }, - }, - default: '', - description: - 'The ID of the user being mentioned. Choose from the list, or specify an ID using an expression.', - }, - { - displayName: 'Page ID', - name: 'page', - type: 'string', - displayOptions: { - show: { - mentionType: ['page'], - }, - }, - default: '', - description: 'The ID of the page being mentioned', - }, - { - displayName: 'Database', - name: 'database', - type: 'resourceLocator', - default: { mode: 'list', value: '' }, - modes: [ - { - displayName: 'Database', - name: 'list', - type: 'list', - placeholder: 'Select a Database...', - typeOptions: { - searchListMethod: 'getDatabases', - searchable: true, +export type BlocksConfig = { + blockTypesLoadOptionsMethod?: string; + databaseSearchListMethod?: string; + displayOptions?: IDisplayOptions; + sortable?: boolean; + usersLoadOptionsMethod?: string; +}; + +const DEFAULT_BLOCKS_CONFIG = { + blockTypesLoadOptionsMethod: 'getBlockTypes', + databaseSearchListMethod: 'getDatabases', + usersLoadOptionsMethod: 'getUsers', +} satisfies Required< + Pick< + BlocksConfig, + 'blockTypesLoadOptionsMethod' | 'databaseSearchListMethod' | 'usersLoadOptionsMethod' + > +>; + +function getBlocksConfig(config: BlocksConfig = {}) { + return { + ...DEFAULT_BLOCKS_CONFIG, + ...config, + }; +} + +const typeMention = (config: BlocksConfig = {}): INodeProperties[] => { + const resolvedConfig = getBlocksConfig(config); + + return [ + { + displayName: 'Type', + name: 'mentionType', + type: 'options', + displayOptions: { + show: { + textType: ['mention'], }, }, - { - displayName: 'Link', - name: 'url', - type: 'string', - placeholder: - 'https://www.notion.com/0fe2f7de558b471eab07e9d871cdf4a9?v=f2d424ba0c404733a3f500c78c881610', - validation: [ - { - type: 'regex', - properties: { - regex: databaseUrlValidationRegexp, - errorMessage: 'Not a valid Notion Database URL', - }, + options: [ + { + name: 'Database', + value: 'database', + }, + { + name: 'Date', + value: 'date', + }, + { + name: 'Page', + value: 'page', + }, + { + name: 'User', + value: 'user', + }, + ], + default: '', + description: + 'An inline mention of a user, page, database, or date. In the app these are created by typing @ followed by the name of a user, page, database, or a date.', + }, + { + displayName: 'User Name or ID', + name: 'user', + type: 'options', + typeOptions: { + loadOptionsMethod: resolvedConfig.usersLoadOptionsMethod, + }, + displayOptions: { + show: { + mentionType: ['user'], + }, + }, + default: '', + description: + 'The ID of the user being mentioned. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Page ID', + name: 'page', + type: 'string', + displayOptions: { + show: { + mentionType: ['page'], + }, + }, + default: '', + description: 'The ID of the page being mentioned', + }, + { + displayName: 'Database', + name: 'database', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'Database', + name: 'list', + type: 'list', + placeholder: 'Select a Database...', + typeOptions: { + searchListMethod: resolvedConfig.databaseSearchListMethod, + searchable: true, }, - ], - extractValue: { - type: 'regex', - regex: databaseUrlExtractionRegexp, }, - }, - { - displayName: 'ID', - name: 'id', - type: 'string', - placeholder: 'ab1545b247fb49fa92d6f4b49f4d8116', - validation: [ - { - type: 'regex', - properties: { - regex: idValidationRegexp, - errorMessage: 'Not a valid Notion Database ID', + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: + 'https://www.notion.com/0fe2f7de558b471eab07e9d871cdf4a9?v=f2d424ba0c404733a3f500c78c881610', + validation: [ + { + type: 'regex', + properties: { + regex: databaseUrlValidationRegexp, + errorMessage: 'Not a valid Notion Database URL', + }, }, + ], + extractValue: { + type: 'regex', + regex: databaseUrlExtractionRegexp, }, - ], - extractValue: { - type: 'regex', - regex: idExtractionRegexp, }, - url: '=https://www.notion.com/{{$value.replace(/-/g, "")}}', - }, - ], - displayOptions: { - show: { - mentionType: ['database'], + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'ab1545b247fb49fa92d6f4b49f4d8116', + validation: [ + { + type: 'regex', + properties: { + regex: idValidationRegexp, + errorMessage: 'Not a valid Notion Database ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: idExtractionRegexp, + }, + url: '=https://www.notion.com/{{$value.replace(/-/g, "")}}', + }, + ], + displayOptions: { + show: { + mentionType: ['database'], + }, }, + description: 'The Notion Database being mentioned', }, - description: 'The Notion Database being mentioned', - }, - { - displayName: 'Range', - name: 'range', - displayOptions: { - show: { - mentionType: ['date'], + { + displayName: 'Range', + name: 'range', + displayOptions: { + show: { + mentionType: ['date'], + }, }, + type: 'boolean', + default: false, + description: 'Whether or not you want to define a date range', }, - type: 'boolean', - default: false, - description: 'Whether or not you want to define a date range', - }, - { - displayName: 'Date', - name: 'date', - displayOptions: { - show: { - mentionType: ['date'], - range: [false], + { + displayName: 'Date', + name: 'date', + displayOptions: { + show: { + mentionType: ['date'], + range: [false], + }, }, + type: 'dateTime', + default: '', + description: 'An ISO 8601 format date, with optional time', }, - type: 'dateTime', - default: '', - description: 'An ISO 8601 format date, with optional time', - }, - { - displayName: 'Date Start', - name: 'dateStart', - displayOptions: { - show: { - mentionType: ['date'], - range: [true], + { + displayName: 'Date Start', + name: 'dateStart', + displayOptions: { + show: { + mentionType: ['date'], + range: [true], + }, }, + type: 'dateTime', + default: '', + description: 'An ISO 8601 format date, with optional time', }, - type: 'dateTime', - default: '', - description: 'An ISO 8601 format date, with optional time', - }, - { - displayName: 'Date End', - name: 'dateEnd', - displayOptions: { - show: { - range: [true], - mentionType: ['date'], + { + displayName: 'Date End', + name: 'dateEnd', + displayOptions: { + show: { + range: [true], + mentionType: ['date'], + }, }, + type: 'dateTime', + default: '', + description: + 'An ISO 8601 formatted date, with optional time. Represents the end of a date range.', }, - type: 'dateTime', - default: '', - description: - 'An ISO 8601 formatted date, with optional time. Represents the end of a date range.', - }, -]; + ]; +}; const typeEquation: INodeProperties[] = [ { @@ -374,7 +404,10 @@ const typeText: INodeProperties[] = [ }, ]; -export const text = (displayOptions: IDisplayOptions): INodeProperties[] => +export const text = ( + displayOptions: IDisplayOptions, + config: BlocksConfig = {}, +): INodeProperties[] => [ { displayName: 'Text', @@ -412,7 +445,7 @@ export const text = (displayOptions: IDisplayOptions): INodeProperties[] => default: 'text', }, ...typeText, - ...typeMention, + ...typeMention(config), ...typeEquation, ...annotation, @@ -490,114 +523,138 @@ const imageBlock = (type: string): INodeProperties[] => [ }, ]; -const block = (blockType: string): INodeProperties[] => { +const block = (blockType: string, config: BlocksConfig = {}): INodeProperties[] => { const data: INodeProperties[] = []; switch (blockType) { case 'to_do': - data.push(...todo(blockType)); - data.push( - ...richText({ + data.push.apply(data, todo(blockType)); + data.push.apply( + data, + richText({ show: { type: [blockType], }, }), ); - data.push( - ...textContent({ + data.push.apply( + data, + textContent({ show: { type: [blockType], richText: [false], }, }), ); - data.push( - ...text({ - show: { - type: [blockType], - richText: [true], + data.push.apply( + data, + text( + { + show: { + type: [blockType], + richText: [true], + }, }, - }), + config, + ), ); break; case 'child_page': - data.push(...title(blockType)); + data.push.apply(data, title(blockType)); break; case 'image': - data.push(...imageBlock(blockType)); + data.push.apply(data, imageBlock(blockType)); break; default: - data.push( - ...richText({ + data.push.apply( + data, + richText({ show: { type: [blockType], }, }), ); - data.push( - ...textContent({ + data.push.apply( + data, + textContent({ show: { type: [blockType], richText: [false], }, }), ); - data.push( - ...text({ - show: { - type: [blockType], - richText: [true], + data.push.apply( + data, + text( + { + show: { + type: [blockType], + richText: [true], + }, }, - }), + config, + ), ); break; } return data; }; -export const blocks = (resource: string, operation: string): INodeProperties[] => [ - { - displayName: 'Blocks', - name: 'blockUi', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, +export const blocks = ( + resource: string, + operation: string, + config: BlocksConfig = {}, +): INodeProperties[] => { + const resolvedConfig = getBlocksConfig(config); + const displayOptions = resolvedConfig.displayOptions ?? { + show: { + resource: [resource], + operation: [operation], }, - default: {}, - displayOptions: { - show: { - resource: [resource], - operation: [operation], - }, - }, - placeholder: 'Add Block', - options: [ - { - name: 'blockValues', - displayName: 'Block', - values: [ - { - displayName: 'Type Name or ID', - name: 'type', - type: 'options', - description: - 'Choose from the list, or specify an ID using an expression', - typeOptions: { - loadOptionsMethod: 'getBlockTypes', + }; + const typeOptions = { + multipleValues: true, + ...(resolvedConfig.sortable ? { sortable: true } : {}), + }; + + return [ + { + displayName: 'Blocks', + name: 'blockUi', + type: 'fixedCollection', + typeOptions, + default: {}, + displayOptions, + placeholder: 'Add Block', + options: [ + { + name: 'blockValues', + displayName: 'Block', + ...(resolvedConfig.sortable ? { typeOptions: { sortable: true } } : {}), + values: [ + { + displayName: 'Type Name or ID', + name: 'type', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: resolvedConfig.blockTypesLoadOptionsMethod, + }, + default: 'paragraph', }, - default: 'paragraph', - }, - ...block('paragraph'), - ...block('heading_1'), - ...block('heading_2'), - ...block('heading_3'), - ...block('toggle'), - ...block('to_do'), - ...block('child_page'), - ...block('bulleted_list_item'), - ...block('numbered_list_item'), - ...block('image'), - ], - }, - ], - }, -]; + ...block('paragraph', resolvedConfig), + ...block('heading_1', resolvedConfig), + ...block('heading_2', resolvedConfig), + ...block('heading_3', resolvedConfig), + ...block('toggle', resolvedConfig), + ...block('to_do', resolvedConfig), + ...block('child_page', resolvedConfig), + ...block('bulleted_list_item', resolvedConfig), + ...block('numbered_list_item', resolvedConfig), + ...block('image', resolvedConfig), + ], + }, + ], + }, + ]; +}; diff --git a/packages/nodes-base/nodes/Notion/test/NotionTrigger.test.ts b/packages/nodes-base/nodes/Notion/test/NotionTrigger.test.ts index 27fa8aaf5fb..31c2bba6469 100644 --- a/packages/nodes-base/nodes/Notion/test/NotionTrigger.test.ts +++ b/packages/nodes-base/nodes/Notion/test/NotionTrigger.test.ts @@ -2,6 +2,7 @@ import moment from 'moment-timezone'; import { deepCopy } from 'n8n-workflow'; import * as GenericFunctions from '../shared/GenericFunctions'; +import * as Transport from '../v3/transport'; import type { Mock } from 'vitest'; vi.mock('../shared/GenericFunctions', async () => ({ @@ -9,24 +10,36 @@ vi.mock('../shared/GenericFunctions', async () => ({ notionApiRequest: vi.fn(), })); +vi.mock('../v3/transport', async () => ({ + ...(await vi.importActual('../v3/transport')), + notionApiRequestV3: vi.fn(), +})); + const mockNotionApiRequest = GenericFunctions.notionApiRequest as Mock; +const mockNotionApiRequestV3 = Transport.notionApiRequestV3 as Mock; function createPollContext( staticData: Record = {}, mode: 'trigger' | 'manual' = 'trigger', + typeVersion = 1.1, ) { return { getWorkflowStaticData: vi.fn().mockReturnValue(staticData), getNodeParameter: vi.fn().mockImplementation((name: string) => { const params: Record = { databaseId: 'test-db-id', + dataSourceId: 'test-data-source-id', event: 'pageAddedToDatabase', simple: false, }; return params[name]; }), getMode: vi.fn().mockReturnValue(mode), - getNode: vi.fn().mockReturnValue({ typeVersion: 1, name: 'Notion Trigger' }), + getNode: vi.fn().mockReturnValue({ + typeVersion, + name: 'Notion Trigger', + type: 'n8n-nodes-base.notionTrigger', + }), helpers: { returnJsonArray: vi .fn() @@ -42,7 +55,7 @@ describe('NotionTrigger', () => { describe('staticData serialization', () => { it('should store lastTimeChecked as a string, not a moment object', async () => { - mockNotionApiRequest.mockResolvedValueOnce({ results: [] }); + mockNotionApiRequestV3.mockResolvedValueOnce({ results: [] }); const staticData: Record = {}; const ctx = createPollContext(staticData); @@ -56,7 +69,7 @@ describe('NotionTrigger', () => { }); it('should survive JSON round-trip serialization', async () => { - mockNotionApiRequest.mockResolvedValueOnce({ results: [] }); + mockNotionApiRequestV3.mockResolvedValueOnce({ results: [] }); const staticData: Record = {}; const ctx = createPollContext(staticData); @@ -74,7 +87,7 @@ describe('NotionTrigger', () => { it('should correctly parse a stored ISO string on subsequent poll', async () => { const previousTimestamp = '2026-04-30T10:00:00Z'; - mockNotionApiRequest.mockResolvedValue({ results: [] }); + mockNotionApiRequestV3.mockResolvedValue({ results: [] }); const staticData: Record = { lastTimeChecked: previousTimestamp, @@ -90,7 +103,7 @@ describe('NotionTrigger', () => { }); it('should have zeroed seconds and milliseconds', async () => { - mockNotionApiRequest.mockResolvedValueOnce({ results: [] }); + mockNotionApiRequestV3.mockResolvedValueOnce({ results: [] }); const staticData: Record = {}; const ctx = createPollContext(staticData); @@ -107,7 +120,7 @@ describe('NotionTrigger', () => { describe('poll behavior', () => { it('should return null when no new pages are found', async () => { - mockNotionApiRequest.mockResolvedValueOnce({ results: [] }); + mockNotionApiRequestV3.mockResolvedValueOnce({ results: [] }); const ctx = createPollContext(); @@ -116,6 +129,34 @@ describe('NotionTrigger', () => { const result = await trigger.poll.call(ctx as never); expect(result).toBeNull(); + expect(mockNotionApiRequestV3).toHaveBeenCalledWith( + 'POST', + '/data_sources/test-data-source-id/query', + expect.objectContaining({ page_size: 1 }), + ); + }); + + it('should keep v1 polling on the legacy database API', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ results: [] }); + + const ctx = createPollContext({}, 'trigger', 1); + + const { NotionTrigger } = await import('../NotionTrigger.node'); + const trigger = new NotionTrigger(); + const result = await trigger.poll.call(ctx as never); + + expect(result).toBeNull(); + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'POST', + '/databases/test-db-id/query', + expect.objectContaining({ page_size: 1 }), + {}, + '', + expect.objectContaining({ + headers: { 'Notion-Version': '2022-02-22' }, + }), + ); + expect(mockNotionApiRequestV3).not.toHaveBeenCalled(); }); it('should return null when the probe returns a record but the follow-up fetch returns an empty page', async () => { @@ -130,7 +171,7 @@ describe('NotionTrigger', () => { .mockResolvedValueOnce({ results: [page] }) .mockResolvedValueOnce({ results: [], has_more: false, next_cursor: null }); - const ctx = createPollContext(); + const ctx = createPollContext({}, 'trigger', 1); const { NotionTrigger } = await import('../NotionTrigger.node'); const trigger = new NotionTrigger(); @@ -147,7 +188,7 @@ describe('NotionTrigger', () => { properties: {}, }; - mockNotionApiRequest.mockResolvedValueOnce({ results: [page] }); + mockNotionApiRequestV3.mockResolvedValueOnce({ results: [page] }); const ctx = createPollContext({}, 'manual'); diff --git a/packages/nodes-base/nodes/Notion/test/NotionV3.node.test.ts b/packages/nodes-base/nodes/Notion/test/NotionV3.node.test.ts new file mode 100644 index 00000000000..ab54eabb8d5 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/test/NotionV3.node.test.ts @@ -0,0 +1,1397 @@ +import get from 'lodash/get'; +import type { + IDataObject, + IExecuteFunctions, + IGetNodeParameterOptions, + INode, + INodeExecutionData, + IPairedItemData, +} from 'n8n-workflow'; +import type { Mock } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as GenericFunctions from '../shared/GenericFunctions'; +import { mapDataSourceFilter } from '../v3/actions/databasePage/DataSourceFilters'; +import { NotionV3 } from '../v3/NotionV3.node'; +import * as Transport from '../v3/transport'; + +vi.mock('../shared/GenericFunctions', async () => ({ + ...(await vi.importActual('../shared/GenericFunctions')), + downloadFiles: vi.fn(), +})); + +vi.mock('../v3/transport', async () => ({ + ...(await vi.importActual('../v3/transport')), + getDataSourceProperties: vi.fn(), + notionApiRequestV3: vi.fn(), + notionApiRequestAllItemsV3: vi.fn(), +})); + +const mockDownloadFiles = GenericFunctions.downloadFiles as Mock; +const mockGetDataSourceProperties = Transport.getDataSourceProperties as Mock; +const mockNotionApiRequest = Transport.notionApiRequestV3 as Mock; +const mockNotionApiRequestAllItems = Transport.notionApiRequestAllItemsV3 as Mock; + +function createMockExecuteFunction( + nodeParameters: IDataObject, + options: { continueOnFail?: boolean } = {}, +): IExecuteFunctions { + return { + getInputData: () => [{ json: {} }], + getNodeParameter( + parameterName: string, + _itemIndex: number, + fallbackValue?: unknown, + options?: IGetNodeParameterOptions, + ) { + const parameter = options?.extractValue ? `${parameterName}.value` : parameterName; + return get(nodeParameters, parameter, fallbackValue); + }, + getNode: () => + ({ + typeVersion: 3, + name: 'Notion', + type: 'n8n-nodes-base.notion', + }) as INode, + getTimezone: () => 'UTC', + continueOnFail: () => options.continueOnFail ?? false, + helpers: { + constructExecutionMetaData: ( + inputData: INodeExecutionData[], + _options: { itemData: IPairedItemData | IPairedItemData[] }, + ) => inputData, + returnJsonArray: (data: IDataObject | IDataObject[]) => + (Array.isArray(data) ? data : [data]).map((d) => ({ json: d })), + }, + } as IExecuteFunctions; +} + +const node = new NotionV3({ + name: 'notion', + displayName: 'Notion', + icon: 'file:notion.svg', + group: ['output'], + defaultVersion: 3, + description: 'Consume Notion API', +}); + +describe('NotionV3', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + [ + 'checkbox', + { key: 'Done|checkbox', condition: 'equals', checkboxValue: true }, + { property: 'Done', checkbox: { equals: true } }, + ], + [ + 'created_by', + { key: 'Created By|created_by', condition: 'contains', peopleValue: 'user-id' }, + { property: 'Created By', people: { contains: 'user-id' } }, + ], + [ + 'created_time', + { key: 'Created Time|created_time', condition: 'before', dateValue: 'today' }, + { timestamp: 'created_time', created_time: { before: 'today' } }, + ], + [ + 'date', + { key: 'Due|date', condition: 'after', dateValue: 'today' }, + { property: 'Due', date: { after: 'today' } }, + ], + [ + 'email', + { key: 'Email|email', condition: 'contains', richTextValue: 'user@example.com' }, + { property: 'Email', rich_text: { contains: 'user@example.com' } }, + ], + [ + 'files', + { key: 'Files|files', condition: 'is_not_empty' }, + { property: 'Files', files: { is_not_empty: true } }, + ], + [ + 'formula checkbox', + { + key: 'Formula Checkbox|formula', + condition: 'equals', + returnType: 'checkbox', + checkboxValue: true, + }, + { property: 'Formula Checkbox', formula: { checkbox: { equals: true } } }, + ], + [ + 'formula date', + { key: 'Formula Date|formula', condition: 'before', returnType: 'date', dateValue: 'today' }, + { property: 'Formula Date', formula: { date: { before: 'today' } } }, + ], + [ + 'formula number', + { + key: 'Formula Number|formula', + condition: 'greater_than', + returnType: 'number', + numberValue: 10, + }, + { property: 'Formula Number', formula: { ['number']: { greater_than: 10 } } }, + ], + [ + 'formula string', + { + key: 'Formula String|formula', + condition: 'contains', + returnType: 'string', + richTextValue: 'Ready', + }, + { property: 'Formula String', formula: { ['string']: { contains: 'Ready' } } }, + ], + [ + 'formula string empty condition', + { + key: 'Formula String|formula', + condition: 'is_empty', + returnType: 'string', + }, + { property: 'Formula String', formula: { ['string']: { is_empty: true } } }, + ], + [ + 'last_edited_by', + { key: 'Last Edited By|last_edited_by', condition: 'contains', peopleValue: 'user-id' }, + { property: 'Last Edited By', people: { contains: 'user-id' } }, + ], + [ + 'last_edited_time', + { key: 'Last Edited Time|last_edited_time', condition: 'on_or_after', dateValue: 'today' }, + { timestamp: 'last_edited_time', last_edited_time: { on_or_after: 'today' } }, + ], + [ + 'multi_select', + { key: 'Tags|multi_select', condition: 'contains', optionValue: 'Bug' }, + { property: 'Tags', multi_select: { contains: 'Bug' } }, + ], + [ + 'number', + { key: 'Estimate|number', condition: 'greater_than', numberValue: 42 }, + { property: 'Estimate', ['number']: { greater_than: 42 } }, + ], + [ + 'people', + { key: 'Assignee|people', condition: 'contains', peopleValue: 'user-id' }, + { property: 'Assignee', people: { contains: 'user-id' } }, + ], + [ + 'phone_number', + { key: 'Phone|phone_number', condition: 'contains', richTextValue: '+491234567890' }, + { property: 'Phone', phone_number: { contains: '+491234567890' } }, + ], + [ + 'relation', + { key: 'Related|relation', condition: 'contains', relationValue: 'page-id' }, + { property: 'Related', relation: { contains: 'page-id' } }, + ], + [ + 'rich_text', + { key: 'Notes|rich_text', condition: 'contains', richTextValue: 'Roadmap' }, + { property: 'Notes', rich_text: { contains: 'Roadmap' } }, + ], + [ + 'rich_text property name with delimiter', + { key: 'Team | Notes|rich_text', condition: 'contains', richTextValue: 'Roadmap' }, + { property: 'Team | Notes', rich_text: { contains: 'Roadmap' } }, + ], + [ + 'rollup', + { key: 'Rollup|rollup', rollupJson: '{"any":{"number":{"greater_than":5}}}' }, + { property: 'Rollup', rollup: { ['any']: { ['number']: { greater_than: 5 } } } }, + ], + [ + 'select', + { key: 'Priority|select', condition: 'equals', optionValue: 'High' }, + { property: 'Priority', select: { equals: 'High' } }, + ], + [ + 'status', + { key: 'Status|status', condition: 'equals', optionValue: 'In Progress' }, + { property: 'Status', status: { equals: 'In Progress' } }, + ], + [ + 'title', + { key: 'Name|title', condition: 'contains', richTextValue: 'Roadmap' }, + { property: 'Name', rich_text: { contains: 'Roadmap' } }, + ], + [ + 'unique_id', + { key: 'Task ID|unique_id', condition: 'greater_than', numberValue: 100 }, + { property: 'Task ID', unique_id: { greater_than: 100 } }, + ], + [ + 'url', + { key: 'Website|url', condition: 'contains', richTextValue: 'example.com' }, + { property: 'Website', rich_text: { contains: 'example.com' } }, + ], + [ + 'verification', + { key: 'Verification|verification', condition: 'status', verificationStatus: 'verified' }, + { property: 'Verification', verification: { status: 'verified' } }, + ], + ])('maps %s manual filters with the UI value field', (_type, filter, expected) => { + expect(mapDataSourceFilter(filter, 'UTC')).toEqual(expected); + }); + + it('creates database pages with a data source parent', async () => { + mockGetDataSourceProperties.mockResolvedValueOnce({ + Name: { type: 'title' }, + }); + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'create', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + title: 'New page', + simple: false, + 'propertiesUi.propertyValues': [], + contentType: 'json', + blocksJson: '[{"object":"block","type":"paragraph","paragraph":{"rich_text":[]}}]', + options: { + icon: '🔥', + }, + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'POST', + '/pages', + expect.objectContaining({ + parent: { type: 'data_source_id', data_source_id: 'data-source-id' }, + children: [{ object: 'block', type: 'paragraph', paragraph: { rich_text: [] } }], + icon: { type: 'emoji', emoji: '🔥' }, + }), + ); + }); + + it('does not create database pages without a title', async () => { + const nodeParameters: IDataObject = { + resource: 'databasePage', + operation: 'create', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + title: ' ', + simple: false, + contentType: 'json', + blocksJson: '[{"object":"block","type":"paragraph","paragraph":{"rich_text":[]}}]', + }; + nodeParameters['dataSourceId.value'] = 'data-source-id'; + nodeParameters['propertiesUi.propertyValues'] = []; + const context = createMockExecuteFunction(nodeParameters); + + await expect(node.execute.call(context)).rejects.toThrow( + 'Title is required to create a database page', + ); + expect(mockGetDataSourceProperties).not.toHaveBeenCalled(); + expect(mockNotionApiRequest).not.toHaveBeenCalled(); + }); + + it('appends blocks after a specific block', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'list', results: [] }); + + const context = createMockExecuteFunction({ + resource: 'block', + operation: 'append', + blockId: { __rl: true, mode: 'id', value: 'parent-block-id' }, + afterBlockId: 'after-block-id', + 'blockUi.blockValues': [ + { + type: 'paragraph', + richText: false, + textContent: 'Hello', + }, + ], + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/blocks/parent-block-id/children', + expect.objectContaining({ + position: { + type: 'after_block', + after_block: { id: 'after-block-id' }, + }, + children: [ + expect.objectContaining({ + type: 'paragraph', + }), + ], + }), + ); + }); + + it('formats shared rich text block values for v3 block append requests', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'list', results: [] }); + + const context = createMockExecuteFunction({ + resource: 'block', + operation: 'append', + blockId: { __rl: true, mode: 'id', value: 'parent-block-id' }, + afterBlockId: '', + 'blockUi.blockValues': [ + { + type: 'paragraph', + richText: true, + text: { + text: [ + { + textType: 'text', + text: 'Linked text', + isLink: true, + textLink: 'https://example.com', + annotationUi: { bold: true }, + }, + { + textType: 'mention', + mentionType: 'database', + database: { + __rl: true, + mode: 'id', + value: 'database-id', + }, + }, + { + textType: 'equation', + expression: 'x = 1', + }, + ], + }, + }, + ], + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/blocks/parent-block-id/children', + expect.objectContaining({ + children: [ + { + object: 'block', + type: 'paragraph', + paragraph: { + rich_text: [ + { + type: 'text', + text: { + content: 'Linked text', + link: { url: 'https://example.com' }, + }, + annotations: { bold: true }, + }, + { + type: 'mention', + mention: { + type: 'database', + database: { id: 'database-id' }, + }, + }, + { + type: 'equation', + equation: { expression: 'x = 1' }, + }, + ], + }, + }, + ], + }), + ); + }); + + it('gets block markdown through the page markdown endpoint', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ + object: 'page_markdown', + id: 'block-id', + markdown: '## Nested content', + }); + + const context = createMockExecuteFunction({ + resource: 'block', + operation: 'getMarkdown', + blockId: { __rl: true, mode: 'id', value: 'block-id' }, + includeTranscript: true, + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'GET', + '/pages/block-id/markdown', + {}, + { include_transcript: true }, + ); + }); + + it('extracts block IDs from notion.com block URL hashes', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ + object: 'page_markdown', + id: '550e8400e29b41d4a716446655440000', + markdown: '## Nested content', + }); + + const context = createMockExecuteFunction({ + resource: 'block', + operation: 'getMarkdown', + blockId: { + __rl: true, + mode: 'url', + value: + 'https://www.notion.com/Block-Test-88888ccc303e4f44847f27d24bd7ad8e?pvs=4#550e8400e29b41d4a716446655440000', + }, + includeTranscript: false, + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'GET', + '/pages/550e8400e29b41d4a716446655440000/markdown', + {}, + {}, + ); + }); + + it('returns item errors when continue on fail is enabled', async () => { + mockNotionApiRequest.mockRejectedValueOnce(new Error('Notion request failed')); + + const context = createMockExecuteFunction( + { + resource: 'block', + operation: 'getMarkdown', + blockId: { __rl: true, mode: 'id', value: 'block-id' }, + includeTranscript: false, + }, + { continueOnFail: true }, + ); + + const result = await node.execute.call(context); + + expect(result[0]).toEqual([ + { + json: { error: 'Notion request failed' }, + pairedItem: { item: 0 }, + }, + ]); + }); + + it('creates pages with file icons', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'page', + operation: 'create', + pageId: { __rl: true, mode: 'id', value: 'parent-page-id' }, + title: 'New page', + contentType: 'markdown', + markdown: '# Content', + simple: false, + options: { + icon: 'https://example.com/icon.png', + }, + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'POST', + '/pages', + expect.objectContaining({ + parent: { page_id: 'parent-page-id' }, + markdown: '# Content', + icon: { type: 'external', external: { url: 'https://example.com/icon.png' } }, + }), + ); + }); + + it('updates page markdown', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page_markdown', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'page', + operation: 'updateMarkdown', + pageId: { __rl: true, mode: 'id', value: 'page-id' }, + markdownUpdateType: 'replace_content', + markdown: '# New content', + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith('PATCH', '/pages/page-id/markdown', { + type: 'replace_content', + replace_content: { new_str: '# New content' }, + }); + }); + + it('extracts page IDs from full URLs with page query parameters', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page_markdown', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'page', + operation: 'updateMarkdown', + pageId: { + __rl: true, + mode: 'url', + value: + 'https://www.notion.com/0fe2f7de558b471eab07e9d871cdf4a9?v=f2d424ba0c404733a3f500c78c881610&p=550e8400e29b41d4a716446655440000&pm=s', + }, + markdownUpdateType: 'replace_content', + markdown: '# New content', + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/pages/550e8400e29b41d4a716446655440000/markdown', + { + type: 'replace_content', + replace_content: { new_str: '# New content' }, + }, + ); + }); + + it('updates page markdown with content updates', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page_markdown', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'page', + operation: 'updateMarkdown', + pageId: { __rl: true, mode: 'id', value: 'page-id' }, + markdownUpdateType: 'update_content', + 'contentUpdates.updates': [ + { + oldString: 'Old text', + newString: 'New text', + replaceAllMatches: true, + }, + ], + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith('PATCH', '/pages/page-id/markdown', { + type: 'update_content', + update_content: { + content_updates: [ + { + old_str: 'Old text', + new_str: 'New text', + replace_all_matches: true, + }, + ], + }, + }); + }); + + it('maps status properties from statusValue', async () => { + mockGetDataSourceProperties.mockResolvedValueOnce({ + Name: { type: 'title' }, + Status: { type: 'status' }, + }); + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'create', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + title: 'New page', + simple: false, + 'propertiesUi.propertyValues': [ + { + key: 'Status|status', + statusValue: 'In progress', + }, + ], + contentType: 'json', + blocksJson: '[]', + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'POST', + '/pages', + expect.objectContaining({ + properties: expect.objectContaining({ + Status: { + type: 'status', + status: { name: 'In progress' }, + }, + }), + }), + ); + }); + + it('maps rich text properties from textContent', async () => { + mockGetDataSourceProperties.mockResolvedValueOnce({ + Name: { type: 'title' }, + ['Strange | Column']: { type: 'rich_text' }, + }); + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'create', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + title: 'New page', + simple: false, + 'propertiesUi.propertyValues': [ + { + key: 'Strange | Column|rich_text', + textContent: 'Plain rich text', + }, + ], + contentType: 'json', + blocksJson: '[]', + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'POST', + '/pages', + expect.objectContaining({ + properties: expect.objectContaining({ + ['Strange | Column']: { + rich_text: [{ text: { content: 'Plain rich text' } }], + }, + }), + }), + ); + }); + + it('maps date properties from date fields', async () => { + mockGetDataSourceProperties.mockResolvedValueOnce({ + Name: { type: 'title' }, + Due: { type: 'date' }, + }); + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'create', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + title: 'New page', + simple: false, + 'propertiesUi.propertyValues': [ + { + key: 'Due|date', + range: false, + includeTime: false, + date: '2026-07-07T10:00:00.000Z', + timezone: 'default', + }, + ], + contentType: 'json', + blocksJson: '[]', + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'POST', + '/pages', + expect.objectContaining({ + properties: expect.objectContaining({ + Due: { + type: 'date', + date: { + start: '2026-07-07', + end: null, + }, + }, + }), + }), + ); + }); + + it('maps date range properties from date range fields', async () => { + mockGetDataSourceProperties.mockResolvedValueOnce({ + Name: { type: 'title' }, + Due: { type: 'date' }, + }); + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'create', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + title: 'New page', + simple: false, + 'propertiesUi.propertyValues': [ + { + key: 'Due|date', + range: true, + includeTime: false, + dateStart: '2026-07-07T10:00:00.000Z', + dateEnd: '2026-07-08T10:00:00.000Z', + timezone: 'default', + }, + ], + contentType: 'json', + blocksJson: '[]', + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'POST', + '/pages', + expect.objectContaining({ + properties: expect.objectContaining({ + Due: { + type: 'date', + date: { + start: '2026-07-07', + end: '2026-07-08', + }, + }, + }), + }), + ); + }); + + it('updates database page date properties', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'update', + pageId: { __rl: true, mode: 'id', value: 'page-id' }, + simple: false, + options: { + icon: 'https://example.com/icon.png', + }, + 'propertiesUi.propertyValues': [ + { + key: 'Due|date', + date: '2026-07-07T10:00:00.000Z', + }, + ], + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/pages/page-id', + expect.objectContaining({ + properties: { + Due: { + type: 'date', + date: { + start: '2026-07-07T10:00:00Z', + end: null, + }, + }, + }, + icon: { type: 'external', external: { url: 'https://example.com/icon.png' } }, + }), + ); + }); + + it('updates database page date range properties', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'update', + pageId: { __rl: true, mode: 'id', value: 'page-id' }, + simple: false, + 'propertiesUi.propertyValues': [ + { + key: 'Due|date', + range: true, + dateStart: '2026-07-05T00:00:00', + dateEnd: '2026-07-11T00:00:00', + }, + ], + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/pages/page-id', + expect.objectContaining({ + properties: { + Due: { + type: 'date', + date: { + start: '2026-07-05T00:00:00Z', + end: '2026-07-11T00:00:00Z', + }, + }, + }, + }), + ); + }); + + it('maps people, relation, files, and empty URL properties', async () => { + mockGetDataSourceProperties.mockResolvedValueOnce({ + Name: { type: 'title' }, + Assignee: { type: 'people' }, + Related: { type: 'relation' }, + Files: { type: 'files' }, + Website: { type: 'url' }, + }); + mockNotionApiRequest.mockResolvedValueOnce({ object: 'page', id: 'page-id' }); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'create', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + title: 'New page', + simple: false, + 'propertiesUi.propertyValues': [ + { + key: 'Assignee|people', + peopleValue: ['user-id-1', 'user-id-2'], + }, + { + key: 'Related|relation', + relationValue: [ + '550e8400-e29b-41d4-a716-446655440000', + '6fa459ea-ee8a-3ca4-894e-db77e160355e', + ], + }, + { + key: 'Files|files', + fileUrls: { + fileUrl: [ + { + name: 'Spec', + url: 'https://example.com/spec.pdf', + }, + ], + }, + }, + { + key: 'Website|url', + urlValue: '', + ignoreIfEmpty: true, + }, + ], + contentType: 'json', + blocksJson: '[]', + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith( + 'POST', + '/pages', + expect.objectContaining({ + properties: expect.objectContaining({ + Assignee: { + type: 'people', + people: [{ id: 'user-id-1' }, { id: 'user-id-2' }], + }, + Related: { + type: 'relation', + relation: [ + { id: '550e8400-e29b-41d4-a716-446655440000' }, + { id: '6fa459ea-ee8a-3ca4-894e-db77e160355e' }, + ], + }, + Files: { + type: 'files', + files: [ + { + name: 'Spec', + type: 'external', + external: { url: 'https://example.com/spec.pdf' }, + }, + ], + }, + }), + }), + ); + expect(mockNotionApiRequest.mock.calls[0][2].properties).not.toHaveProperty('Website'); + }); + + it('fetches nested blocks when requested', async () => { + mockNotionApiRequestAllItems + .mockResolvedValueOnce([ + { + object: 'block', + id: 'child-block-id', + type: 'paragraph', + has_children: true, + }, + ]) + .mockResolvedValueOnce([ + { + object: 'block', + id: 'nested-block-id', + type: 'paragraph', + has_children: false, + }, + ]); + + const context = createMockExecuteFunction({ + resource: 'block', + operation: 'getAll', + blockId: { __rl: true, mode: 'id', value: 'parent-block-id' }, + returnAll: true, + fetchNestedBlocks: true, + simplifyOutput: false, + }); + + const result = await node.execute.call(context); + + expect(mockNotionApiRequestAllItems).toHaveBeenCalledWith( + 'results', + 'GET', + '/blocks/parent-block-id/children', + {}, + {}, + ); + expect(mockNotionApiRequestAllItems).toHaveBeenCalledWith( + 'results', + 'GET', + '/blocks/child-block-id/children', + ); + expect(result[0].map((item) => item.json.id)).toEqual(['child-block-id', 'nested-block-id']); + }); + + it('queries data source pages with structured filters and sorting', async () => { + mockNotionApiRequestAllItems.mockResolvedValueOnce([]); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'getAll', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + returnAll: false, + limit: 10, + filterType: 'manual', + matchType: 'allFilters', + 'filters.conditions': [ + { + key: 'Name|title', + type: 'title', + condition: 'contains', + richTextValue: 'Roadmap', + }, + ], + options: { + sort: { + sortValue: [ + { + timestamp: false, + key: 'Due|date', + direction: 'descending', + }, + ], + }, + }, + simple: false, + }); + + await node.execute.call(context); + + expect(mockNotionApiRequestAllItems).toHaveBeenCalledWith( + 'results', + 'POST', + '/data_sources/data-source-id/query', + { + filter: { + and: [ + { + property: 'Name', + rich_text: { contains: 'Roadmap' }, + }, + ], + }, + sorts: [ + { + direction: 'descending', + property: 'Due', + }, + ], + page_size: 10, + }, + { limit: 10 }, + ); + }); + + it('queries data source pages with timestamp filter syntax', async () => { + mockNotionApiRequestAllItems.mockResolvedValueOnce([]); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'getAll', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + returnAll: true, + filterType: 'manual', + matchType: 'anyFilter', + 'filters.conditions': [ + { + key: 'Created Time|created_time', + type: 'created_time', + condition: 'this_week', + }, + ], + options: { + sort: { + sortValue: [], + }, + }, + simple: false, + }); + + await node.execute.call(context); + + expect(mockNotionApiRequestAllItems).toHaveBeenCalledWith( + 'results', + 'POST', + '/data_sources/data-source-id/query', + { + filter: { + or: [ + { + timestamp: 'created_time', + created_time: { this_week: {} }, + }, + ], + }, + }, + {}, + ); + }); + + it('downloads files from data source page results when requested', async () => { + const page = { + object: 'page', + id: 'page-id', + properties: { + Files: { + type: 'files', + files: [{ external: { url: 'https://example.com/file.pdf' } }], + }, + }, + }; + mockNotionApiRequestAllItems.mockResolvedValueOnce([page]); + mockDownloadFiles.mockResolvedValueOnce([{ json: page, binary: { file: {} } }]); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'getAll', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + returnAll: true, + filterType: 'none', + options: { + sort: { + sortValue: [], + }, + downloadFiles: true, + }, + simple: false, + }); + + const result = await node.execute.call(context); + + expect(mockDownloadFiles).toHaveBeenCalledWith([page], [{ item: 0 }]); + expect(result[0]).toEqual([{ json: page, binary: { file: {} } }]); + }); + + it('downloads files from a single database page when requested', async () => { + const page = { + object: 'page', + id: 'page-id', + properties: { + Files: { + type: 'files', + files: [{ external: { url: 'https://example.com/file.pdf' } }], + }, + }, + }; + mockNotionApiRequest.mockResolvedValueOnce(page); + mockDownloadFiles.mockResolvedValueOnce([{ json: page, binary: { file: {} } }]); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'get', + pageId: { __rl: true, mode: 'id', value: 'page-id' }, + options: { + downloadFiles: true, + }, + simple: false, + }); + + const result = await node.execute.call(context); + + expect(mockDownloadFiles).toHaveBeenCalledWith([page], [{ item: 0 }]); + expect(result[0]).toEqual([{ json: page, binary: { file: {} } }]); + }); + + it('keeps URLs when simplifying data source pages', async () => { + mockNotionApiRequestAllItems.mockResolvedValueOnce([ + { + object: 'page', + id: 'page-id', + url: 'https://www.notion.com/Page-pageid', + properties: { + Name: { + type: 'title', + title: [{ type: 'text', plain_text: 'Roadmap' }], + }, + }, + }, + ]); + + const context = createMockExecuteFunction({ + resource: 'databasePage', + operation: 'getAll', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + returnAll: true, + filterType: 'none', + options: { + sort: { + sortValue: [], + }, + }, + simple: true, + }); + + const result = await node.execute.call(context); + + expect(result[0][0].json).toEqual({ + id: 'page-id', + name: 'Roadmap', + url: 'https://www.notion.com/Page-pageid', + property_name: 'Roadmap', + }); + }); + + it('searches pages with sort options', async () => { + mockNotionApiRequestAllItems.mockResolvedValueOnce([]); + + const context = createMockExecuteFunction({ + resource: 'page', + operation: 'search', + text: 'Roadmap', + returnAll: false, + limit: 10, + options: { + sort: { + sortValue: { + timestamp: 'last_edited_time', + direction: 'ascending', + }, + }, + }, + simple: false, + }); + + await node.execute.call(context); + + expect(mockNotionApiRequestAllItems).toHaveBeenCalledWith( + 'results', + 'POST', + '/search', + expect.objectContaining({ + query: 'Roadmap', + filter: { property: 'object', value: 'page' }, + sort: { timestamp: 'last_edited_time', direction: 'ascending' }, + page_size: 10, + }), + { limit: 10 }, + ); + }); + + it('appends text-bearing block-builder blocks from textContent', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'list', results: [] }); + + const context = createMockExecuteFunction({ + resource: 'block', + operation: 'append', + blockId: { __rl: true, mode: 'id', value: 'parent-block-id' }, + afterBlockId: '', + 'blockUi.blockValues': [ + { + type: 'paragraph', + textContent: 'Paragraph text', + }, + { + type: 'heading_1', + textContent: 'Heading text', + }, + { + type: 'to_do', + textContent: 'Task text', + checked: true, + }, + { + type: 'child_page', + title: 'Nested child page', + }, + ], + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith('PATCH', '/blocks/parent-block-id/children', { + children: [ + expect.objectContaining({ + type: 'paragraph', + paragraph: { rich_text: [{ text: { content: 'Paragraph text' } }] }, + }), + expect.objectContaining({ + type: 'heading_1', + heading_1: { rich_text: [{ text: { content: 'Heading text' } }] }, + }), + expect.objectContaining({ + type: 'to_do', + to_do: { + checked: true, + rich_text: [{ text: { content: 'Task text' } }], + }, + }), + expect.objectContaining({ + type: 'child_page', + child_page: { title: 'Nested child page' }, + }), + ], + }); + }); + + it('gets a data source directly', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ object: 'data_source', id: 'data-source-id' }); + + const context = createMockExecuteFunction({ + resource: 'dataSource', + operation: 'get', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + }); + + await node.execute.call(context); + + expect(mockNotionApiRequest).toHaveBeenCalledWith('GET', '/data_sources/data-source-id'); + }); + + it('simplifies a data source get response', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ + object: 'data_source', + id: 'data-source-id', + name: 'Tasks', + url: 'https://notion.so/data-source-id', + properties: { Name: { type: 'title' } }, + }); + + const context = createMockExecuteFunction({ + resource: 'dataSource', + operation: 'get', + 'dataSourceId.value': 'data-source-id', + dataSourceId: { __rl: true, mode: 'id', value: 'data-source-id' }, + simple: true, + }); + + const result = await node.execute.call(context); + + expect(result[0][0].json).toEqual({ + id: 'data-source-id', + name: 'Tasks', + url: 'https://notion.so/data-source-id', + }); + }); + + it('searches data sources from database search results', async () => { + mockNotionApiRequestAllItems.mockResolvedValueOnce([ + { + object: 'data_source', + id: 'data-source-id', + url: 'https://notion.so/database-id', + title: [{ plain_text: 'Tasks' }], + parent: { type: 'database_id', database_id: 'database-id' }, + }, + ]); + + const context = createMockExecuteFunction({ + resource: 'dataSource', + operation: 'search', + text: 'Tasks', + returnAll: false, + limit: 10, + options: { + sort: { + sortValue: { + timestamp: 'last_edited_time', + direction: 'descending', + }, + }, + }, + }); + + const result = await node.execute.call(context); + + expect(mockNotionApiRequestAllItems).toHaveBeenCalledWith( + 'results', + 'POST', + '/search', + expect.objectContaining({ + filter: { property: 'object', value: 'data_source' }, + query: 'Tasks', + sort: { timestamp: 'last_edited_time', direction: 'descending' }, + page_size: 10, + }), + { limit: 10 }, + ); + expect(result[0][0].json).toEqual({ + object: 'data_source', + id: 'data-source-id', + url: 'https://notion.so/database-id', + title: [{ plain_text: 'Tasks' }], + parent: { type: 'database_id', database_id: 'database-id' }, + }); + }); + + it('simplifies data source search results', async () => { + mockNotionApiRequestAllItems.mockResolvedValueOnce([ + { + object: 'data_source', + id: 'data-source-id', + name: 'Tasks', + url: 'https://notion.so/data-source-id', + parent: { type: 'database_id', database_id: 'database-id' }, + }, + ]); + + const context = createMockExecuteFunction({ + resource: 'dataSource', + operation: 'search', + text: 'Tasks', + returnAll: true, + simple: true, + }); + + const result = await node.execute.call(context); + + expect(result[0][0].json).toEqual({ + id: 'data-source-id', + name: 'Tasks', + url: 'https://notion.so/data-source-id', + }); + }); +}); diff --git a/packages/nodes-base/nodes/Notion/test/v3/listSearch.test.ts b/packages/nodes-base/nodes/Notion/test/v3/listSearch.test.ts new file mode 100644 index 00000000000..b32f08b9214 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/test/v3/listSearch.test.ts @@ -0,0 +1,60 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { getDatabases, getDataSources } from '../../v3/methods/listSearch'; +import * as Transport from '../../v3/transport'; + +vi.mock('../../v3/transport', async () => ({ + ...(await vi.importActual('../../v3/transport')), + notionApiRequestAllItemsV3: vi.fn(), +})); + +const mockNotionApiRequestAllItems = Transport.notionApiRequestAllItemsV3 as Mock; +const context = {} as ILoadOptionsFunctions; + +describe('Notion V3 list search', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses all title plain text fragments when listing data sources without names', async () => { + mockNotionApiRequestAllItems.mockResolvedValueOnce([ + { + id: 'data-source-id', + url: 'https://notion.so/data-source-id', + title: [{ plain_text: 'Customer ' }, { plain_text: 'Support' }], + }, + ]); + + const result = await getDataSources.call(context); + + expect(result.results).toEqual([ + { + name: 'Customer Support', + value: 'data-source-id', + url: 'https://notion.so/data-source-id', + }, + ]); + }); + + it('uses all title plain text fragments when listing parent databases', async () => { + mockNotionApiRequestAllItems.mockResolvedValueOnce([ + { + id: 'data-source-id', + url: 'https://notion.so/database-id', + parent: { database_id: 'database-id' }, + title: [{ plain_text: 'Product ' }, { plain_text: 'Roadmap' }], + }, + ]); + + const result = await getDatabases.call(context); + + expect(result.results).toEqual([ + { + name: 'Product Roadmap', + value: 'database-id', + url: 'https://notion.so/database-id', + }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Notion/test/v3/loadOptions.test.ts b/packages/nodes-base/nodes/Notion/test/v3/loadOptions.test.ts new file mode 100644 index 00000000000..2309a217f75 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/test/v3/loadOptions.test.ts @@ -0,0 +1,110 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { + getDataSourceOptionsFromPage, + getDataSourcePropertiesFromPage, + getPropertySelectValues, +} from '../../v3/methods/loadOptions'; +import * as Transport from '../../v3/transport'; + +vi.mock('../../v3/transport', async () => ({ + ...(await vi.importActual('../../v3/transport')), + getDataSourceProperties: vi.fn(), + notionApiRequestV3: vi.fn(), +})); + +const mockGetDataSourceProperties = Transport.getDataSourceProperties as Mock; +const mockNotionApiRequest = Transport.notionApiRequestV3 as Mock; + +function createLoadOptionsContext(parameters: Record): ILoadOptionsFunctions { + return { + getCurrentNodeParameter: vi.fn( + (name: string, fallback?: unknown) => parameters[name] ?? fallback, + ), + } as unknown as ILoadOptionsFunctions; +} + +describe('Notion V3 load options', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns no options when property select values are requested before choosing a property', async () => { + const context = createLoadOptionsContext({ '&key': '' }); + + const result = await getPropertySelectValues.call(context); + + expect(result).toEqual([]); + }); + + it('returns no options when page property options are requested before choosing a property', async () => { + const context = createLoadOptionsContext({ '&key': '' }); + + const result = await getDataSourceOptionsFromPage.call(context); + + expect(result).toEqual([]); + }); + + it('uses only the last key segment as the property type for selected data source options', async () => { + mockGetDataSourceProperties.mockResolvedValueOnce({ + 'Stage | Owner': { + type: 'select', + select: { + options: [{ name: 'Ready' }], + }, + }, + }); + const context = createLoadOptionsContext({ + '&key': 'Stage | Owner|select', + dataSourceId: 'data-source-id', + }); + + const result = await getPropertySelectValues.call(context); + + expect(result).toEqual([{ name: 'Ready', value: 'Ready' }]); + }); + + it('uses only the last key segment as the property type for page data source options', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ + parent: { + type: 'data_source_id', + data_source_id: 'data-source-id', + }, + }); + mockGetDataSourceProperties.mockResolvedValueOnce({ + 'Tags | Segment': { + type: 'multi_select', + multi_select: { + options: [{ name: 'Enterprise' }], + }, + }, + }); + const context = createLoadOptionsContext({ + '&key': 'Tags | Segment|multi_select', + pageId: 'page-id', + }); + + const result = await getDataSourceOptionsFromPage.call(context); + + expect(result).toEqual([{ name: 'Enterprise', value: 'Enterprise' }]); + }); + + it('does not use a page parent database ID as a data source ID', async () => { + mockNotionApiRequest.mockResolvedValueOnce({ + parent: { + type: 'database_id', + database_id: 'database-id', + }, + }); + const context = createLoadOptionsContext({ + pageId: 'page-id', + }); + + const result = await getDataSourcePropertiesFromPage.call(context); + + expect(result).toEqual([]); + expect(mockNotionApiRequest).toHaveBeenCalledWith('GET', '/pages/page-id'); + expect(mockGetDataSourceProperties).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Notion/test/v3/transport.test.ts b/packages/nodes-base/nodes/Notion/test/v3/transport.test.ts new file mode 100644 index 00000000000..ecc4a69bc9c --- /dev/null +++ b/packages/nodes-base/nodes/Notion/test/v3/transport.test.ts @@ -0,0 +1,27 @@ +import type { IExecuteFunctions, INode } from 'n8n-workflow'; + +import { notionApiRequestV3 } from '../../v3/transport'; + +describe('Notion V3 transport', () => { + it('always sends the 2026 Notion API version header', async () => { + const httpRequestWithAuthentication = vi.fn().mockResolvedValue({}); + const context = { + getNodeParameter: vi.fn().mockReturnValue('apiKey'), + getNode: () => ({ name: 'Notion', type: 'n8n-nodes-base.notion', typeVersion: 3 }) as INode, + helpers: { + httpRequestWithAuthentication, + }, + } as unknown as IExecuteFunctions; + + await notionApiRequestV3.call(context, 'GET', '/users'); + + expect(httpRequestWithAuthentication).toHaveBeenCalledWith( + 'notionApi', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Notion-Version': '2026-03-11', + }), + }), + ); + }); +}); diff --git a/packages/nodes-base/nodes/Notion/test/v3/utils.test.ts b/packages/nodes-base/nodes/Notion/test/v3/utils.test.ts new file mode 100644 index 00000000000..154ddde79fc --- /dev/null +++ b/packages/nodes-base/nodes/Notion/test/v3/utils.test.ts @@ -0,0 +1,45 @@ +import { formatBlocks } from '../../v3/helpers/utils'; + +describe('Notion V3 utils', () => { + describe('formatBlocks', () => { + it('formats numeric textContent values for regular text-bearing blocks', () => { + const result = formatBlocks([ + { + type: 'paragraph', + textContent: 123, + }, + ]); + + expect(result).toEqual([ + { + object: 'block', + type: 'paragraph', + paragraph: { + rich_text: [{ text: { content: '123' } }], + }, + }, + ]); + }); + + it('formats numeric textContent values for to-do blocks', () => { + const result = formatBlocks([ + { + type: 'to_do', + checked: true, + textContent: 456, + }, + ]); + + expect(result).toEqual([ + { + object: 'block', + type: 'to_do', + to_do: { + checked: true, + rich_text: [{ text: { content: '456' } }], + }, + }, + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Notion/v1/NotionV1.node.ts b/packages/nodes-base/nodes/Notion/v1/NotionV1.node.ts index 1cc7a817345..5ed35f6b769 100644 --- a/packages/nodes-base/nodes/Notion/v1/NotionV1.node.ts +++ b/packages/nodes-base/nodes/Notion/v1/NotionV1.node.ts @@ -13,9 +13,9 @@ import type { import { versionDescription } from './VersionDescription'; import type { SortData } from '../shared/GenericFunctions'; import { - extractDatabaseId, extractDatabaseMentionRLC, extractPageId, + extractResourceId, formatBlocks, formatTitle, getBlockTypesOptions, @@ -295,7 +295,7 @@ export class NotionV1 implements INodeType { if (resource === 'database') { if (operation === 'get') { for (let i = 0; i < length; i++) { - const databaseId = extractDatabaseId( + const databaseId = extractResourceId( this.getNodeParameter('databaseId', i, '', { extractValue: true }) as string, ); responseData = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); diff --git a/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts b/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts index c858df243d2..1c43b808976 100644 --- a/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts +++ b/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts @@ -14,8 +14,8 @@ import type { SortData, FileRecord } from '../shared/GenericFunctions'; import { downloadFiles, extractBlockId, - extractDatabaseId, extractDatabaseMentionRLC, + extractResourceId, getPageId, formatBlocks, formatTitle, @@ -175,7 +175,7 @@ export class NotionV2 implements INodeType { const simple = this.getNodeParameter('simple', 0) as boolean; for (let i = 0; i < itemsLength; i++) { try { - const databaseId = extractDatabaseId( + const databaseId = extractResourceId( this.getNodeParameter('databaseId', i, '', { extractValue: true }) as string, ); responseData = await notionApiRequest.call(this, 'GET', `/databases/${databaseId}`); diff --git a/packages/nodes-base/nodes/Notion/v3/NotionV3.node.ts b/packages/nodes-base/nodes/Notion/v3/NotionV3.node.ts new file mode 100644 index 00000000000..986ee788357 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/NotionV3.node.ts @@ -0,0 +1,27 @@ +import type { + IExecuteFunctions, + INodeTypeBaseDescription, + INodeTypeDescription, + INodeType, +} from 'n8n-workflow'; + +import { router } from './actions/router'; +import { listSearch, loadOptions } from './methods'; +import { versionDescription } from './VersionDescription'; + +export class NotionV3 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { listSearch, loadOptions }; + + async execute(this: IExecuteFunctions) { + return await router.call(this); + } +} diff --git a/packages/nodes-base/nodes/Notion/v3/VersionDescription.ts b/packages/nodes-base/nodes/Notion/v3/VersionDescription.ts new file mode 100644 index 00000000000..4c93686457e --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/VersionDescription.ts @@ -0,0 +1,115 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow'; + +import * as block from './actions/block/Block.resource'; +import * as database from './actions/database/Database.resource'; +import * as databasePage from './actions/databasePage/DatabasePage.resource'; +import * as dataSource from './actions/dataSource/DataSource.resource'; +import * as page from './actions/page/Page.resource'; +import * as user from './actions/user/User.resource'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Notion', + name: 'notion', + icon: { light: 'file:notion.svg', dark: 'file:notion.dark.svg' }, + group: ['output'], + version: 3, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Notion API', + defaults: { + name: 'Notion', + }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + usableAsTool: true, + credentials: [ + { + name: 'notionApi', + required: true, + displayOptions: { + show: { + authentication: ['apiKey'], + }, + }, + }, + { + name: 'notionOAuth2Api', + 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: + 'In Notion, make sure to add your connection to the pages you want to access.', + name: 'notionNotice', + type: 'notice', + default: '', + }, + { + displayName: '', + name: 'Credentials', + type: 'credentials', + default: '', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Block', + value: 'block', + }, + { + name: 'Data Source', + value: 'dataSource', + }, + { + name: 'Database', + value: 'database', + }, + { + name: 'Database Page', + value: 'databasePage', + }, + { + name: 'Page', + value: 'page', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'page', + }, + ...block.description, + ...dataSource.description, + ...database.description, + ...databasePage.description, + ...page.description, + ...user.description, + ], +}; diff --git a/packages/nodes-base/nodes/Notion/v3/actions/block/Block.resource.ts b/packages/nodes-base/nodes/Notion/v3/actions/block/Block.resource.ts new file mode 100644 index 00000000000..fabfa712751 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/block/Block.resource.ts @@ -0,0 +1,239 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; + +import { + extractBlockId, + formatBlocks, + handleOperationError, + normalizeBlockValues, + simplifyBlocksOutput, +} from '../../helpers/utils'; +import { isDataObject, notionApiRequestAllItemsV3, notionApiRequestV3 } from '../../transport'; +import { blockBuilder, blockId, returnAllOrLimit } from '../common.descriptions'; + +async function fetchNestedBlocks( + this: IExecuteFunctions, + blocks: IDataObject[], + responseData: IDataObject[] = [], + limit?: number, +) { + for (const block of blocks) { + responseData.push(block); + const blockId = block.id; + + if (limit && responseData.length >= limit) { + return responseData.slice(0, limit); + } + + if ( + typeof blockId !== 'string' || + block.type === 'child_page' || + block.type === 'unsupported' || + !block.has_children + ) { + continue; + } + + const children = await notionApiRequestAllItemsV3.call( + this, + 'results', + 'GET', + `/blocks/${blockId}/children`, + ); + const nestedBlocks = children.map((entry) => ({ + object: entry.object, + parent_id: blockId, + ...entry, + })); + + await fetchNestedBlocks.call(this, nestedBlocks, responseData, limit); + + if (limit && responseData.length >= limit) { + return responseData.slice(0, limit); + } + } + + return responseData; +} + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['block'] } }, + options: [ + { + name: 'Append After', + value: 'append', + description: 'Append a block', + action: 'Append a block', + }, + { + name: 'Get Markdown', + value: 'getMarkdown', + description: 'Get block markdown', + action: 'Get block markdown', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many child blocks', + action: 'Get many child blocks', + }, + ], + default: 'append', + }, + { + ...blockId, + displayName: 'Parent Block', + displayOptions: { show: { resource: ['block'], operation: ['append'] } }, + description: 'The Notion block to append blocks to', + }, + { + displayName: 'Insert After Block', + name: 'afterBlockId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['block'], operation: ['append'] } }, + description: + 'ID of the block after which to insert the new blocks. Leave empty to append at the end.', + }, + blockBuilder('block', 'append'), + { + ...blockId, + displayOptions: { show: { resource: ['block'], operation: ['getAll'] } }, + description: 'The Notion block to get children from', + }, + { + ...blockId, + displayOptions: { show: { resource: ['block'], operation: ['getMarkdown'] } }, + description: 'The Notion block to get markdown from', + }, + { + displayName: 'Include Transcript', + name: 'includeTranscript', + type: 'boolean', + default: false, + displayOptions: { show: { resource: ['block'], operation: ['getMarkdown'] } }, + }, + ...returnAllOrLimit('block', 'getAll'), + { + displayName: 'Fetch Nested Blocks', + name: 'fetchNestedBlocks', + type: 'boolean', + default: false, + displayOptions: { show: { resource: ['block'], operation: ['getAll'] } }, + }, + { + displayName: 'Simplify Output', + name: 'simplifyOutput', + type: 'boolean', + default: true, + displayOptions: { show: { resource: ['block'], operation: ['getAll'] } }, + }, +]; + +export async function append(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const blockIdValue = extractBlockId.call(this, i); + const rawBlockValues: unknown = this.getNodeParameter('blockUi.blockValues', i, []); + const blockValues = Array.isArray(rawBlockValues) ? rawBlockValues.filter(isDataObject) : []; + const blockValuesData = normalizeBlockValues(blockValues); + const afterBlockId = this.getNodeParameter('afterBlockId', i, '') as string; + const body: IDataObject = { children: formatBlocks(blockValuesData) }; + if (afterBlockId) { + body.position = { + type: 'after_block', + after_block: { id: afterBlockId }, + }; + } + const response = await notionApiRequestV3.call( + this, + 'PATCH', + `/blocks/${blockIdValue}/children`, + body, + ); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function getMarkdown(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const blockIdValue = extractBlockId.call(this, i); + const includeTranscript = this.getNodeParameter('includeTranscript', i) as boolean; + // uses page endpoint, but it supports block ids too + const response = await notionApiRequestV3.call( + this, + 'GET', + `/pages/${blockIdValue}/markdown`, + {}, + includeTranscript ? { include_transcript: true } : {}, + ); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + + return returnData; +} + +export async function getAll(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const blockIdValue = extractBlockId.call(this, i); + const returnAll = this.getNodeParameter('returnAll', i); + const limit = returnAll ? undefined : this.getNodeParameter('limit', i); + let response: IDataObject[] = await notionApiRequestAllItemsV3.call( + this, + 'results', + 'GET', + `/blocks/${blockIdValue}/children`, + {}, + limit ? { page_size: Math.min(limit, 100), limit } : {}, + ); + const fetchNestedBlocksOption = this.getNodeParameter('fetchNestedBlocks', i) as boolean; + if (fetchNestedBlocksOption) { + response = await fetchNestedBlocks.call(this, response, [], limit); + } + const simplifyOutput = this.getNodeParameter('simplifyOutput', i) as boolean; + if (simplifyOutput) { + response = simplifyBlocksOutput(response, blockIdValue); + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/Notion/v3/actions/common.descriptions.ts b/packages/nodes-base/nodes/Notion/v3/actions/common.descriptions.ts new file mode 100644 index 00000000000..1e3ce430fe5 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/common.descriptions.ts @@ -0,0 +1,326 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { + blockUrlExtractionRegexp, + blockUrlValidationRegexp, + databasePageUrlExtractionRegexp, + databasePageUrlValidationRegexp, + databaseUrlExtractionRegexp, + databaseUrlValidationRegexp, + idExtractionRegexp, + idValidationRegexp, +} from '../../shared/constants'; +import { blocks } from '../../shared/descriptions/Blocks'; + +export const blockId: INodeProperties = { + displayName: 'Block', + name: 'blockId', + type: 'resourceLocator', + default: { mode: 'url', value: '' }, + required: true, + modes: [ + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: + 'https://www.notion.com/Block-Test-88888ccc303e4f44847f27d24bd7ad8e?pvs=4#c44444444444bbbbb4d32fdfdd84e', + validation: [ + { + type: 'regex', + properties: { + regex: blockUrlValidationRegexp, + errorMessage: 'Not a valid Notion Block URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: blockUrlExtractionRegexp, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'ab1545b247fb49fa92d6f4b49f4d8116', + validation: [ + { + type: 'regex', + properties: { + regex: idValidationRegexp, + errorMessage: 'Not a valid Notion Block ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: idExtractionRegexp, + }, + }, + ], +}; + +export const databaseLocator: INodeProperties = { + displayName: 'Database', + name: 'databaseId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'Database', + name: 'list', + type: 'list', + placeholder: 'Select a Database...', + typeOptions: { + searchListMethod: 'getDatabases', + searchable: true, + }, + }, + { + displayName: 'Database Link', + name: 'url', + type: 'string', + placeholder: + 'https://www.notion.com/0fe2f7de558b471eab07e9d871cdf4a9?v=f2d424ba0c404733a3f500c78c881610', + validation: [ + { + type: 'regex', + properties: { + regex: databaseUrlValidationRegexp, + errorMessage: 'Not a valid Notion Database URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: databaseUrlExtractionRegexp, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'ab1545b247fb49fa92d6f4b49f4d8116', + validation: [ + { + type: 'regex', + properties: { + regex: idValidationRegexp, + errorMessage: 'Not a valid Notion Database ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: idExtractionRegexp, + }, + }, + ], + description: 'The Notion database to operate on', +}; + +export const dataSourceLocator: INodeProperties = { + displayName: 'Data Source', + name: 'dataSourceId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'Data Source', + name: 'list', + type: 'list', + placeholder: 'Select a Data Source...', + typeOptions: { + searchListMethod: 'getDataSources', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'ab1545b247fb49fa92d6f4b49f4d8116', + validation: [ + { + type: 'regex', + properties: { + regex: idValidationRegexp, + errorMessage: 'Not a valid Notion Data Source ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: idExtractionRegexp, + }, + }, + ], + description: 'The Notion data source to operate on', +}; + +export const pageLocator: INodeProperties = { + displayName: 'Page', + name: 'pageId', + type: 'resourceLocator', + default: { mode: 'url', value: '' }, + required: true, + modes: [ + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'https://www.notion.com/My-Page-b4eeb113e118403aa450af65ac25f0b9', + validation: [ + { + type: 'regex', + properties: { + regex: databasePageUrlValidationRegexp, + errorMessage: 'Not a valid Notion Page URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: databasePageUrlExtractionRegexp, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'ab1545b247fb49fa92d6f4b49f4d8116', + validation: [ + { + type: 'regex', + properties: { + regex: idValidationRegexp, + errorMessage: 'Not a valid Notion Page ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: idExtractionRegexp, + }, + }, + ], +}; + +export function blockBuilder( + resource: string, + operation: string, + extraDisplayOptions: { contentType?: string[] } = {}, +): INodeProperties { + const show: Record = { + resource: [resource], + operation: [operation], + }; + if (extraDisplayOptions.contentType) { + show.contentType = extraDisplayOptions.contentType; + } + + return blocks(resource, operation, { + displayOptions: { show }, + sortable: true, + })[0]; +} + +export function iconOptions(resource: string, operations: string[]): INodeProperties { + return { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add option', + displayOptions: { show: { resource: [resource], operation: operations } }, + options: [ + { + displayName: 'Icon', + name: 'icon', + type: 'string', + default: '', + description: 'Emoji or file URL to use as the icon', + }, + ], + }; +} + +export function searchOptions(resource: string, operation: string): INodeProperties { + return { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add Field', + displayOptions: { show: { resource: [resource], operation: [operation] } }, + options: [ + { + displayName: 'Sort', + name: 'sort', + placeholder: 'Add Sort', + type: 'fixedCollection', + typeOptions: { multipleValues: false }, + default: {}, + options: [ + { + displayName: 'Sort', + name: 'sortValue', + values: [ + { + displayName: 'Direction', + name: 'direction', + type: 'options', + options: [ + { name: 'Ascending', value: 'ascending' }, + { name: 'Descending', value: 'descending' }, + ], + default: 'descending', + description: 'The direction to sort', + }, + { + displayName: 'Timestamp', + name: 'timestamp', + type: 'options', + options: [{ name: 'Last Edited Time', value: 'last_edited_time' }], + default: 'last_edited_time', + description: 'The name of the timestamp to sort against', + }, + ], + }, + ], + }, + ], + }; +} + +export const returnAllOrLimit = (resource: string, operation: string): INodeProperties[] => [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + displayOptions: { show: { resource: [resource], operation: [operation] } }, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { minValue: 1 }, + default: 50, + displayOptions: { show: { resource: [resource], operation: [operation], returnAll: [false] } }, + description: 'Max number of results to return', + }, +]; + +export const simplify = (resource: string, operations: string[]): INodeProperties => ({ + displayName: 'Simplify', + name: 'simple', + type: 'boolean', + default: true, + displayOptions: { show: { resource: [resource], operation: operations } }, + description: 'Whether to return a simplified version of the response instead of the raw data', +}); diff --git a/packages/nodes-base/nodes/Notion/v3/actions/dataSource/DataSource.resource.ts b/packages/nodes-base/nodes/Notion/v3/actions/dataSource/DataSource.resource.ts new file mode 100644 index 00000000000..60e763bf119 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/dataSource/DataSource.resource.ts @@ -0,0 +1,143 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; + +import { extractResourceId } from '../../../shared/GenericFunctions'; +import { + flattenDataSources, + getSearchSort, + handleOperationError, + simplifyObjects, +} from '../../helpers/utils'; +import { notionApiRequestAllItemsV3, notionApiRequestV3 } from '../../transport'; +import { + dataSourceLocator, + returnAllOrLimit, + searchOptions, + simplify, +} from '../common.descriptions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['dataSource'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a data source', + action: 'Get a data source', + }, + { + name: 'Search', + value: 'search', + description: 'Search data sources', + action: 'Search data sources', + }, + ], + default: 'get', + }, + { + ...dataSourceLocator, + displayOptions: { + show: { + resource: ['dataSource'], + operation: ['get'], + }, + }, + description: 'The Notion data source to retrieve', + }, + { + displayName: 'Search Text', + name: 'text', + type: 'string', + default: '', + displayOptions: { + show: { + resource: ['dataSource'], + operation: ['search'], + }, + }, + description: 'Text to search databases/data sources for', + }, + ...returnAllOrLimit('dataSource', 'search'), + simplify('dataSource', ['get', 'search']), + searchOptions('dataSource', 'search'), +]; + +export async function get(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const selectedDataSourceId = this.getNodeParameter('dataSourceId', i, '', { + extractValue: true, + }) as string; + const dataSourceId = extractResourceId(selectedDataSourceId); + let response: IDataObject | IDataObject[] = await notionApiRequestV3.call( + this, + 'GET', + `/data_sources/${dataSourceId}`, + ); + if (this.getNodeParameter('simple', i) as boolean) + response = simplifyObjects(response, false, 3); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function search(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const text = this.getNodeParameter('text', i) as string; + const returnAll = this.getNodeParameter('returnAll', i); + const body: IDataObject = { filter: { property: 'object', value: 'data_source' } }; + if (text) body.query = text; + const sort = getSearchSort.call(this, i); + if (sort) body.sort = sort; + + const limit = returnAll ? undefined : this.getNodeParameter('limit', i); + if (limit) body.page_size = Math.min(limit, 100); + + const response = await notionApiRequestAllItemsV3.call( + this, + 'results', + 'POST', + '/search', + body, + limit ? { limit } : {}, + ); + let dataSources: IDataObject | IDataObject[] = flattenDataSources(response); + if (this.getNodeParameter('simple', i) as boolean) { + dataSources = simplifyObjects(dataSources, false, 3); + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(dataSources), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/Notion/v3/actions/database/Database.resource.ts b/packages/nodes-base/nodes/Notion/v3/actions/database/Database.resource.ts new file mode 100644 index 00000000000..63a5571b0e8 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/database/Database.resource.ts @@ -0,0 +1,72 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; + +import { handleOperationError, simplifyObjects } from '../../helpers/utils'; +import { notionApiRequestV3 } from '../../transport'; +import { databaseLocator } from '../common.descriptions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['database'] } }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get database metadata and its data sources', + action: 'Get a database', + }, + ], + default: 'get', + }, + { + ...databaseLocator, + displayName: 'Database', + name: 'databaseId', + displayOptions: { show: { resource: ['database'], operation: ['get'] } }, + description: + 'The Notion database to retrieve. Use Data Source operations to search, query, or create database pages.', + }, + { + displayName: 'Simplify', + name: 'simple', + type: 'boolean', + default: true, + displayOptions: { show: { resource: ['database'], operation: ['get'] } }, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, +]; + +export async function get(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const databaseId = this.getNodeParameter('databaseId', i, '', { + extractValue: true, + }) as string; + let response: IDataObject | IDataObject[] = await notionApiRequestV3.call( + this, + 'GET', + `/databases/${databaseId}`, + ); + if (this.getNodeParameter('simple', i) as boolean) { + response = simplifyObjects(response, false, 3); + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/Notion/v3/actions/databasePage/DataSourceFilters.ts b/packages/nodes-base/nodes/Notion/v3/actions/databasePage/DataSourceFilters.ts new file mode 100644 index 00000000000..464d94244d7 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/databasePage/DataSourceFilters.ts @@ -0,0 +1,549 @@ +import { capitalCase } from 'change-case'; +import moment from 'moment-timezone'; +import type { IDataObject, INodeProperties } from 'n8n-workflow'; + +import { splitPropertyKey } from '../../helpers/utils'; + +type FilterCondition = IDataObject & { + key?: string; + type?: string; + condition?: string; + returnType?: string; +}; + +const EMPTY_CONDITIONS = ['is_empty', 'is_not_empty']; +const RELATIVE_DATE_CONDITIONS = [ + 'next_month', + 'next_week', + 'next_year', + 'past_month', + 'past_week', + 'past_year', + 'this_week', +]; + +const CONDITION_OPTIONS: Record = { + checkbox: ['equals', 'does_not_equal'], + date: [ + 'equals', + 'before', + 'after', + 'on_or_before', + 'on_or_after', + 'is_empty', + 'is_not_empty', + 'next_month', + 'next_week', + 'next_year', + 'past_month', + 'past_week', + 'past_year', + 'this_week', + ], + files: ['is_empty', 'is_not_empty'], + multi_select: ['contains', 'does_not_contain', 'is_empty', 'is_not_empty'], + number: [ + 'equals', + 'does_not_equal', + 'greater_than', + 'less_than', + 'greater_than_or_equal_to', + 'less_than_or_equal_to', + 'is_empty', + 'is_not_empty', + ], + people: ['contains', 'does_not_contain', 'is_empty', 'is_not_empty'], + phone_number: [ + 'equals', + 'does_not_equal', + 'contains', + 'does_not_contain', + 'starts_with', + 'ends_with', + 'is_empty', + 'is_not_empty', + ], + relation: ['contains', 'does_not_contain', 'is_empty', 'is_not_empty'], + rich_text: [ + 'equals', + 'does_not_equal', + 'contains', + 'does_not_contain', + 'starts_with', + 'ends_with', + 'is_empty', + 'is_not_empty', + ], + select: ['equals', 'does_not_equal', 'is_empty', 'is_not_empty'], + status: ['equals', 'does_not_equal', 'is_empty', 'is_not_empty'], + unique_id: [ + 'equals', + 'does_not_equal', + 'greater_than', + 'less_than', + 'greater_than_or_equal_to', + 'less_than_or_equal_to', + ], + verification: ['status'], +}; + +const TYPE_TO_FILTER_TYPE: Record = { + created_by: 'people', + created_time: 'timestamp', + email: 'rich_text', + last_edited_by: 'people', + last_edited_time: 'timestamp', + phone_number: 'phone_number', + title: 'rich_text', + url: 'rich_text', +}; + +const FORMULA_RETURN_TYPES = ['checkbox', 'date', 'number', 'string']; +const ROLLUP_FILTER_HINT = + 'Provide the value for the Notion rollup filter object, for example {"number":{"greater_than":10}} or {"any":{"rich_text":{"contains":"Task"}}}.'; + +function conditionOptions(type: string) { + return (CONDITION_OPTIONS[type] ?? []).map((entry) => ({ + name: capitalCase(entry), + value: entry, + })); +} + +function typedConditionOptions(types: string[]) { + return types.flatMap((type) => { + const apiType = TYPE_TO_FILTER_TYPE[type] ?? type; + const options = conditionOptions(apiType === 'timestamp' ? 'date' : apiType); + if (!options.length) return []; + + return { + displayName: 'Condition', + name: 'condition', + type: 'options', + displayOptions: { show: { type: [type] } }, + options, + default: '', + description: 'The condition to filter by', + } satisfies INodeProperties; + }); +} + +function splitList(value: unknown) { + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string'); + if (typeof value !== 'string') return ''; + + const entries = value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + return entries.length > 1 ? entries : value; +} + +function mapDateValue(value: unknown, timezone: string) { + if (typeof value !== 'string') return ''; + if (!value) return ''; + + return Number.isNaN(Date.parse(value)) ? value : moment.tz(value, timezone).utc().format(); +} + +function getFilterType(type: string) { + return TYPE_TO_FILTER_TYPE[type] ?? type; +} + +function assertConditionAllowed(filterType: string, condition: string) { + const allowedConditions = CONDITION_OPTIONS[filterType]; + if (!allowedConditions?.includes(condition)) { + throw new Error( + `The condition "${condition}" is not supported for Notion ${filterType} filters`, + ); + } +} + +function conditionValue( + filterType: string, + condition: string, + filter: FilterCondition, + timezone: string, +) { + if (EMPTY_CONDITIONS.includes(condition)) return true; + if (RELATIVE_DATE_CONDITIONS.includes(condition)) return {}; + + switch (filterType) { + case 'checkbox': + return filter.checkboxValue === true; + case 'date': + return mapDateValue(filter.dateValue, timezone); + case 'number': + case 'unique_id': + return filter.numberValue; + case 'people': + return filter.peopleValue; + case 'phone_number': + return filter.richTextValue; + case 'relation': + return filter.relationValue; + case 'rich_text': + return filter.richTextValue; + case 'select': + case 'status': + case 'multi_select': + return splitList(filter.optionValue); + case 'verification': + return filter.verificationStatus; + default: + return ''; + } +} + +function typeCondition( + filterType: string, + condition: string, + filter: FilterCondition, + timezone: string, +) { + assertConditionAllowed(filterType, condition); + return { [condition]: conditionValue(filterType, condition, filter, timezone) }; +} + +function mapRollupFilter(filter: FilterCondition) { + if (typeof filter.rollupJson !== 'string' || !filter.rollupJson) return {}; + + try { + return JSON.parse(filter.rollupJson) as IDataObject; + } catch { + throw new Error('Rollup Filter (JSON) must be valid JSON'); + } +} + +export function mapDataSourceFilter(filter: FilterCondition, timezone: string) { + if (typeof filter.key !== 'string') return {}; + + const { name, type } = splitPropertyKey(filter.key); + const filterType = getFilterType(type); + + if (filterType === 'rollup') { + return { + property: name, + rollup: mapRollupFilter(filter), + }; + } + + if (typeof filter.condition !== 'string') return {}; + + if (filterType === 'timestamp') { + const timestamp = type; + return { + timestamp, + [timestamp]: typeCondition('date', filter.condition, filter, timezone), + }; + } + + if (filterType === 'formula') { + const formulaType = filter.returnType === 'string' ? 'string' : filter.returnType; + if (typeof formulaType !== 'string' || !FORMULA_RETURN_TYPES.includes(formulaType)) { + throw new Error('Choose a formula return type before filtering'); + } + + return { + property: name, + formula: { + [formulaType]: typeCondition( + formulaType === 'string' ? 'rich_text' : formulaType, + filter.condition, + filter, + timezone, + ), + }, + }; + } + + return { + property: name, + [filterType]: typeCondition(filterType, filter.condition, filter, timezone), + }; +} + +export function mapDataSourceFilters(filters: IDataObject[], matchType: string, timezone: string) { + const mappedFilters = filters.map((filter) => mapDataSourceFilter(filter, timezone)); + if (!mappedFilters.length) return undefined; + + return matchType === 'allFilters' ? { and: mappedFilters } : { or: mappedFilters }; +} + +export function dataSourceSearchFilterDescriptions(): INodeProperties[] { + const propertyTypes = [ + 'checkbox', + 'created_by', + 'created_time', + 'date', + 'email', + 'files', + 'formula', + 'last_edited_by', + 'last_edited_time', + 'multi_select', + 'number', + 'people', + 'phone_number', + 'relation', + 'rich_text', + 'rollup', + 'select', + 'status', + 'title', + 'unique_id', + 'url', + 'verification', + ]; + + return [ + { + displayName: 'Filter', + name: 'filterType', + type: 'options', + options: [ + { name: 'None', value: 'none' }, + { name: 'Build Manually', value: 'manual' }, + { name: 'JSON', value: 'json' }, + ], + displayOptions: { show: { resource: ['databasePage'], operation: ['getAll'] } }, + default: 'none', + }, + { + displayName: 'Must Match', + name: 'matchType', + type: 'options', + options: [ + { name: 'Any Filter', value: 'anyFilter' }, + { name: 'All Filters', value: 'allFilters' }, + ], + displayOptions: { + show: { resource: ['databasePage'], operation: ['getAll'], filterType: ['manual'] }, + }, + default: 'anyFilter', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'fixedCollection', + typeOptions: { multipleValues: true }, + displayOptions: { + show: { resource: ['databasePage'], operation: ['getAll'], filterType: ['manual'] }, + }, + default: {}, + placeholder: 'Add Condition', + options: [ + { + displayName: 'Conditions', + name: 'conditions', + values: [ + { + displayName: 'Property Name or ID', + name: 'key', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getFilterProperties', + loadOptionsDependsOn: ['dataSourceId'], + }, + default: '', + description: + 'The name of the property to filter by. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Type', + name: 'type', + type: 'hidden', + default: '={{$parameter["&key"].split("|").pop()}}', + }, + ...typedConditionOptions( + propertyTypes.filter((type) => type !== 'formula' && type !== 'rollup'), + ), + { + displayName: 'Formula Return Type', + name: 'returnType', + type: 'options', + displayOptions: { show: { type: ['formula'] } }, + options: FORMULA_RETURN_TYPES.map((type) => ({ + name: capitalCase(type), + value: type, + })), + default: 'string', + }, + ...FORMULA_RETURN_TYPES.flatMap((returnType) => + typedConditionOptions([returnType === 'string' ? 'rich_text' : returnType]).map( + (condition) => ({ + ...condition, + displayOptions: { show: { type: ['formula'], returnType: [returnType] } }, + }), + ), + ), + { + displayName: 'Rollup Filter (JSON)', + name: 'rollupJson', + type: 'json', + typeOptions: { rows: 4 }, + default: '{}', + displayOptions: { show: { type: ['rollup'] } }, + description: ROLLUP_FILTER_HINT, + }, + { + displayName: 'Text', + name: 'richTextValue', + type: 'string', + default: '', + displayOptions: { + show: { + type: ['email', 'phone_number', 'rich_text', 'title', 'url'], + }, + hide: { condition: [...EMPTY_CONDITIONS] }, + }, + }, + { + displayName: 'Text', + name: 'richTextValue', + type: 'string', + default: '', + displayOptions: { + show: { + type: ['formula'], + returnType: ['string'], + }, + hide: { condition: [...EMPTY_CONDITIONS] }, + }, + }, + { + displayName: 'Number', + name: 'numberValue', + type: 'number', + default: 0, + displayOptions: { + show: { type: ['number', 'unique_id'] }, + hide: { condition: [...EMPTY_CONDITIONS] }, + }, + }, + { + displayName: 'Number', + name: 'numberValue', + type: 'number', + default: 0, + displayOptions: { + show: { type: ['formula'], returnType: ['number'] }, + hide: { condition: [...EMPTY_CONDITIONS] }, + }, + }, + { + displayName: 'Checked', + name: 'checkboxValue', + type: 'boolean', + default: false, + displayOptions: { show: { type: ['checkbox'] } }, + }, + { + displayName: 'Checked', + name: 'checkboxValue', + type: 'boolean', + default: false, + displayOptions: { show: { type: ['formula'], returnType: ['checkbox'] } }, + }, + { + displayName: 'Date', + name: 'dateValue', + type: 'string', + default: '', + description: + 'ISO 8601 date or a Notion relative date value like today, tomorrow, yesterday, one_week_ago, or one_month_from_now', + displayOptions: { + show: { + type: ['created_time', 'date', 'last_edited_time'], + }, + hide: { + condition: [...EMPTY_CONDITIONS, ...RELATIVE_DATE_CONDITIONS], + }, + }, + }, + { + displayName: 'Date', + name: 'dateValue', + type: 'string', + default: '', + description: + 'ISO 8601 date or a Notion relative date value like today, tomorrow, yesterday, one_week_ago, or one_month_from_now', + displayOptions: { + show: { + type: ['formula'], + returnType: ['date'], + }, + hide: { + condition: [...EMPTY_CONDITIONS, ...RELATIVE_DATE_CONDITIONS], + }, + }, + }, + { + displayName: 'Option Name(s)', + name: 'optionValue', + type: 'string', + default: '', + description: + 'Option name. For select, status, and multi-select filters that support multiple values, separate names with commas.', + displayOptions: { + show: { type: ['multi_select', 'select', 'status'] }, + hide: { condition: [...EMPTY_CONDITIONS] }, + }, + }, + { + displayName: 'User ID or Me', + name: 'peopleValue', + type: 'string', + default: '', + displayOptions: { + show: { type: ['created_by', 'last_edited_by', 'people'] }, + hide: { condition: [...EMPTY_CONDITIONS] }, + }, + }, + { + displayName: 'Relation Page ID', + name: 'relationValue', + type: 'string', + default: '', + displayOptions: { + show: { type: ['relation'] }, + hide: { condition: [...EMPTY_CONDITIONS] }, + }, + }, + { + displayName: 'Verification Status', + name: 'verificationStatus', + type: 'options', + options: [ + { name: 'Verified', value: 'verified' }, + { name: 'Expired', value: 'expired' }, + { name: 'None', value: 'none' }, + ], + default: 'verified', + displayOptions: { show: { type: ['verification'] } }, + }, + ], + }, + ], + }, + { + displayName: + 'See Notion guide to creating data source filters', + name: 'jsonNotice', + type: 'notice', + displayOptions: { + show: { resource: ['databasePage'], operation: ['getAll'], filterType: ['json'] }, + }, + default: '', + }, + { + displayName: 'Filters (JSON)', + name: 'filterJson', + type: 'json', + typeOptions: { rows: 8 }, + default: '{}', + displayOptions: { + show: { resource: ['databasePage'], operation: ['getAll'], filterType: ['json'] }, + }, + }, + ]; +} diff --git a/packages/nodes-base/nodes/Notion/v3/actions/databasePage/DatabasePage.resource.ts b/packages/nodes-base/nodes/Notion/v3/actions/databasePage/DatabasePage.resource.ts new file mode 100644 index 00000000000..7bf973786f1 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/databasePage/DatabasePage.resource.ts @@ -0,0 +1,695 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { NodeOperationError, setSafeObjectProperty } from 'n8n-workflow'; + +import { dataSourceSearchFilterDescriptions, mapDataSourceFilters } from './DataSourceFilters'; +import { downloadFiles, type FileRecord } from '../../../shared/GenericFunctions'; +import { + getIconFromOptions, + getPageCreateContent, + getPageId, + handleOperationError, + jsonParse, + mapProperties, + mapSorting, + normalizePropertyValues, + simplifyObjects, + type SortData, + validateJSON, +} from '../../helpers/utils'; +import { + getDataSourceProperties, + notionApiRequestAllItemsV3, + notionApiRequestV3, +} from '../../transport'; +import { + blockBuilder, + dataSourceLocator, + iconOptions, + pageLocator, + returnAllOrLimit, +} from '../common.descriptions'; + +const PROPERTY_KEY_DESCRIPTION = + 'Choose from the list, or specify an ID using an expression. Use the format Property Name|property_type, for example Due Date|date.'; + +function getQueryOptions(): INodeProperties { + return { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add Field', + displayOptions: { show: { resource: ['databasePage'], operation: ['get', 'getAll'] } }, + options: [ + { + displayName: 'Sort', + name: 'sort', + placeholder: 'Add Sort', + type: 'fixedCollection', + typeOptions: { multipleValues: true }, + default: {}, + displayOptions: { show: { '/operation': ['getAll'] } }, + options: [ + { + displayName: 'Sort', + name: 'sortValue', + values: [ + { + displayName: 'Timestamp', + name: 'timestamp', + type: 'boolean', + default: false, + description: "Whether or not to use the record's timestamp to sort the response", + }, + { + displayName: 'Property Name or ID', + name: 'key', + type: 'options', + displayOptions: { show: { timestamp: [false] } }, + typeOptions: { + loadOptionsMethod: 'getFilterProperties', + loadOptionsDependsOn: ['dataSourceId'], + }, + default: '', + description: + 'The name of the property to filter by. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Property Name', + name: 'key', + type: 'options', + options: [ + { name: 'Created Time', value: 'created_time' }, + { name: 'Last Edited Time', value: 'last_edited_time' }, + ], + displayOptions: { show: { timestamp: [true] } }, + default: '', + description: 'The name of the property to filter by', + }, + { + displayName: 'Type', + name: 'type', + type: 'hidden', + displayOptions: { show: { timestamp: [true] } }, + default: '={{$parameter["&key"].split("|").pop()}}', + }, + { + displayName: 'Direction', + name: 'direction', + type: 'options', + options: [ + { name: 'Ascending', value: 'ascending' }, + { name: 'Descending', value: 'descending' }, + ], + default: '', + description: 'The direction to sort', + }, + ], + }, + ], + }, + { + displayName: 'Download Files', + name: 'downloadFiles', + type: 'boolean', + default: false, + description: "Whether to download a file if a page's property contains it", + }, + ], + }; +} + +function propertiesUi( + operation: string, + keyLoadOptionsMethod: string, + keyLoadOptionsDependsOn: string[], + selectLoadOptionsMethod: string, + selectLoadOptionsDependsOn: string[], +): INodeProperties { + return { + displayName: 'Properties', + name: 'propertiesUi', + type: 'fixedCollection', + typeOptions: { multipleValues: true }, + default: {}, + placeholder: 'Add Property', + displayOptions: { show: { resource: ['databasePage'], operation: [operation] } }, + options: [ + { + name: 'propertyValues', + displayName: 'Property', + values: [ + { + displayName: 'Key Name or ID', + name: 'key', + type: 'options', + description: PROPERTY_KEY_DESCRIPTION, + typeOptions: { + loadOptionsMethod: keyLoadOptionsMethod, + loadOptionsDependsOn: keyLoadOptionsDependsOn, + }, + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'hidden', + default: '={{$parameter["&key"].split("|").pop()}}', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + displayOptions: { show: { type: ['title'] } }, + }, + { + displayName: 'Text', + name: 'textContent', + type: 'string', + default: '', + displayOptions: { show: { type: ['rich_text'] } }, + }, + { + displayName: 'Number', + name: 'numberValue', + type: 'number', + default: 0, + displayOptions: { show: { type: ['number'] } }, + }, + { + displayName: 'Checkbox', + name: 'checkboxValue', + type: 'boolean', + default: false, + displayOptions: { show: { type: ['checkbox'] } }, + }, + { + displayName: 'Select Name or ID', + name: 'selectValue', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: selectLoadOptionsMethod, + loadOptionsDependsOn: selectLoadOptionsDependsOn, + }, + default: '', + displayOptions: { show: { type: ['select'] } }, + }, + { + displayName: 'Status Name or ID', + name: 'statusValue', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsMethod: selectLoadOptionsMethod, + loadOptionsDependsOn: selectLoadOptionsDependsOn, + }, + default: '', + displayOptions: { show: { type: ['status'] } }, + }, + { + displayName: 'Multi Select', + name: 'multiSelectValue', + type: 'string', + default: '', + displayOptions: { show: { type: ['multi_select'] } }, + }, + { + displayName: 'URL', + name: 'urlValue', + type: 'string', + default: '', + displayOptions: { show: { type: ['url'] } }, + }, + { + displayName: 'Ignore If Empty', + name: 'ignoreIfEmpty', + type: 'boolean', + default: false, + displayOptions: { show: { type: ['url'] } }, + }, + { + displayName: 'Email', + name: 'emailValue', + type: 'string', + default: '', + displayOptions: { show: { type: ['email'] } }, + }, + { + displayName: 'Phone', + name: 'phoneValue', + type: 'string', + default: '', + displayOptions: { show: { type: ['phone_number'] } }, + }, + { + displayName: 'User Names or IDs', + name: 'peopleValue', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + default: [], + displayOptions: { show: { type: ['people'] } }, + description: + 'List of users. Choose from the list, or specify IDs using an expression.', + }, + { + displayName: 'Relation IDs', + name: 'relationValue', + type: 'string', + typeOptions: { + multipleValues: true, + }, + default: [], + displayOptions: { show: { type: ['relation'] } }, + description: 'List of related page IDs', + }, + { + displayName: 'Range', + name: 'range', + type: 'boolean', + default: false, + displayOptions: { show: { type: ['date'] } }, + description: 'Whether to define a date range', + }, + { + displayName: 'Include Time', + name: 'includeTime', + type: 'boolean', + default: true, + displayOptions: { show: { type: ['date'] } }, + description: 'Whether to include the time in the date', + }, + { + displayName: 'Date', + name: 'date', + type: 'dateTime', + default: '', + displayOptions: { show: { type: ['date'], range: [false] } }, + description: 'An ISO 8601 format date, with optional time', + }, + { + displayName: 'Date Start', + name: 'dateStart', + type: 'dateTime', + default: '', + displayOptions: { show: { type: ['date'], range: [true] } }, + description: 'An ISO 8601 format date, with optional time', + }, + { + displayName: 'Date End', + name: 'dateEnd', + type: 'dateTime', + default: '', + displayOptions: { show: { type: ['date'], range: [true] } }, + description: + 'An ISO 8601 formatted date, with optional time. Represents the end of a date range.', + }, + { + displayName: 'Timezone Name or ID', + name: 'timezone', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTimezones', + }, + default: 'default', + displayOptions: { show: { type: ['date'] } }, + description: + 'Time zone to use. By default n8n timezone is used. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'File URLs', + name: 'fileUrls', + placeholder: 'Add File', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + displayOptions: { show: { type: ['files'] } }, + options: [ + { + name: 'fileUrl', + displayName: 'File', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + }, + { + displayName: 'File URL', + name: 'url', + type: 'string', + default: '', + description: 'Link to externally hosted file', + }, + ], + }, + ], + }, + ], + }, + ], + }; +} + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['databasePage'] } }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a page in a data source', + action: 'Create a database page', + }, + { name: 'Get', value: 'get', description: 'Get a page', action: 'Get a database page' }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many pages', + action: 'Get many database pages', + }, + { + name: 'Update', + value: 'update', + description: 'Update a page', + action: 'Update a database page', + }, + ], + default: 'create', + }, + { + ...dataSourceLocator, + displayOptions: { show: { resource: ['databasePage'], operation: ['create', 'getAll'] } }, + }, + { + ...pageLocator, + displayName: 'Page', + displayOptions: { show: { resource: ['databasePage'], operation: ['get', 'update'] } }, + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + displayOptions: { show: { resource: ['databasePage'], operation: ['create'] } }, + description: 'Page title', + }, + propertiesUi( + 'create', + 'getDataSourcePropertiesOptions', + ['dataSourceId'], + 'getPropertySelectValues', + ['dataSourceId', '&key'], + ), + propertiesUi( + 'update', + 'getDataSourcePropertiesFromPage', + ['pageId'], + 'getDataSourceOptionsFromPage', + ['pageId', '&key'], + ), + { + displayName: 'Content Type', + name: 'contentType', + type: 'options', + options: [ + { name: 'Block Builder', value: 'blockUi' }, + { name: 'JSON Blocks', value: 'json' }, + { name: 'Markdown', value: 'markdown' }, + ], + default: 'blockUi', + displayOptions: { show: { resource: ['databasePage'], operation: ['create'] } }, + }, + { + displayName: 'Blocks (JSON)', + name: 'blocksJson', + type: 'string', + typeOptions: { rows: 8 }, + default: '', + displayOptions: { + show: { resource: ['databasePage'], operation: ['create'], contentType: ['json'] }, + }, + }, + blockBuilder('databasePage', 'create', { contentType: ['blockUi'] }), + { + displayName: 'Markdown', + name: 'markdown', + type: 'string', + typeOptions: { rows: 8 }, + default: '', + displayOptions: { + show: { resource: ['databasePage'], operation: ['create'], contentType: ['markdown'] }, + }, + }, + iconOptions('databasePage', ['create', 'update']), + ...returnAllOrLimit('databasePage', 'getAll'), + ...dataSourceSearchFilterDescriptions(), + { + displayName: 'Simplify', + name: 'simple', + type: 'boolean', + default: true, + displayOptions: { + show: { resource: ['databasePage'], operation: ['create', 'get', 'getAll', 'update'] }, + }, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, + getQueryOptions(), +]; + +async function getTitleKey(this: IExecuteFunctions, dataSourceId: string) { + const properties = await getDataSourceProperties.call(this, dataSourceId); + for (const key of Object.keys(properties)) { + const property = properties[key]; + if ( + typeof property === 'object' && + property !== null && + 'type' in property && + property.type === 'title' + ) { + return key; + } + } + return ''; +} + +export async function create(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const dataSourceId = this.getNodeParameter('dataSourceId', i, '', { + extractValue: true, + }) as string; + if (!dataSourceId) { + throw new NodeOperationError( + this.getNode(), + 'DataSource ID is required to create a database page', + { + itemIndex: i, + }, + ); + } + const title = this.getNodeParameter('title', i, '') as string; + if (!title.trim()) { + throw new NodeOperationError( + this.getNode(), + 'Title is required to create a database page', + { + itemIndex: i, + }, + ); + } + + const titleKey = await getTitleKey.call(this, dataSourceId); + const properties: IDataObject = {}; + if (titleKey) { + setSafeObjectProperty(properties, titleKey, { title: [{ text: { content: title } }] }); + } + const propertyValues = this.getNodeParameter( + 'propertiesUi.propertyValues', + i, + [], + ) as IDataObject[]; + if (propertyValues.length) { + const mappedProperties = mapProperties.call( + this, + normalizePropertyValues(propertyValues), + this.getTimezone(), + 3, + ); + for (const [key, value] of Object.entries(mappedProperties)) { + setSafeObjectProperty(properties, key, value); + } + } + const body: IDataObject = { + parent: { type: 'data_source_id', data_source_id: dataSourceId }, + properties, + ...getPageCreateContent.call(this, i), + }; + const icon = getIconFromOptions.call(this, i); + if (icon) body.icon = icon; + let response: IDataObject | IDataObject[] = await notionApiRequestV3.call( + this, + 'POST', + '/pages', + body, + ); + if (this.getNodeParameter('simple', i) as boolean) + response = simplifyObjects(response, false, 3); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function get(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + let response: IDataObject | IDataObject[] = await notionApiRequestV3.call( + this, + 'GET', + `/pages/${getPageId.call(this, i)}`, + ); + const download = this.getNodeParameter('options.downloadFiles', i, false) as boolean; + const simple = this.getNodeParameter('simple', i) as boolean; + let executionData: INodeExecutionData[]; + if (download) { + executionData = await downloadFiles.call(this, [response as FileRecord], [{ item: i }]); + if (simple) executionData = simplifyObjects(executionData, true, 3) as INodeExecutionData[]; + } else { + if (simple) response = simplifyObjects(response, false, 3); + executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + } + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function getAll(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const selectedDataSourceId = this.getNodeParameter('dataSourceId', i, '', { + extractValue: true, + }) as string; + const dataSourceId = selectedDataSourceId; + const returnAll = this.getNodeParameter('returnAll', i); + const filterType = this.getNodeParameter('filterType', i) as string; + const body: IDataObject = {}; + if (filterType === 'manual') { + const matchType = this.getNodeParameter('matchType', i) as string; + const conditions = this.getNodeParameter('filters.conditions', i, []) as IDataObject[]; + body.filter = mapDataSourceFilters(conditions, matchType, this.getTimezone()); + } else if (filterType === 'json') { + const filterJson = this.getNodeParameter('filterJson', i) as string; + if (validateJSON(filterJson) === undefined) { + throw new NodeOperationError(this.getNode(), 'Filters (JSON) must be valid JSON', { + itemIndex: i, + }); + } + body.filter = jsonParse(filterJson); + } + if (!Object.keys((body.filter as IDataObject | undefined) ?? {}).length) { + delete body.filter; + } + const sort = this.getNodeParameter('options.sort.sortValue', i, []) as SortData[]; + if (sort.length) { + body.sorts = mapSorting(sort); + } + const limit = returnAll ? undefined : this.getNodeParameter('limit', i); + if (limit) body.page_size = Math.min(limit, 100); + const response: IDataObject[] = await notionApiRequestAllItemsV3.call( + this, + 'results', + 'POST', + `/data_sources/${dataSourceId}/query`, + body, + limit ? { limit } : {}, + ); + const download = this.getNodeParameter('options.downloadFiles', i, false) as boolean; + const simple = this.getNodeParameter('simple', i) as boolean; + let executionData: INodeExecutionData[]; + if (download) { + executionData = await downloadFiles.call(this, response as FileRecord[], [{ item: i }]); + if (simple) executionData = simplifyObjects(executionData, true, 3) as INodeExecutionData[]; + } else { + const output = simple ? simplifyObjects(response, false, 3) : response; + executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(output), + { itemData: { item: i } }, + ); + } + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function update(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const propertyValues = this.getNodeParameter( + 'propertiesUi.propertyValues', + i, + [], + ) as IDataObject[]; + const normalizedPropertyValues = normalizePropertyValues(propertyValues); + const body: IDataObject = { + properties: normalizedPropertyValues.length + ? mapProperties.call(this, normalizedPropertyValues, this.getTimezone(), 3) + : {}, + }; + const icon = getIconFromOptions.call(this, i); + if (icon) body.icon = icon; + let response: IDataObject | IDataObject[] = await notionApiRequestV3.call( + this, + 'PATCH', + `/pages/${getPageId.call(this, i)}`, + body, + ); + if (this.getNodeParameter('simple', i) as boolean) + response = simplifyObjects(response, false, 3); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/Notion/v3/actions/node.type.ts b/packages/nodes-base/nodes/Notion/v3/actions/node.type.ts new file mode 100644 index 00000000000..3420c226740 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/node.type.ts @@ -0,0 +1,10 @@ +export type NotionV3Type = + | { resource: 'block'; operation: 'append' | 'getAll' | 'getMarkdown' } + | { resource: 'dataSource'; operation: 'get' | 'search' } + | { resource: 'database'; operation: 'get' } + | { resource: 'databasePage'; operation: 'create' | 'get' | 'getAll' | 'update' } + | { + resource: 'page'; + operation: 'archive' | 'create' | 'getMarkdown' | 'search' | 'updateMarkdown'; + } + | { resource: 'user'; operation: 'get' | 'getAll' }; diff --git a/packages/nodes-base/nodes/Notion/v3/actions/page/Page.resource.ts b/packages/nodes-base/nodes/Notion/v3/actions/page/Page.resource.ts new file mode 100644 index 00000000000..4adc83eb641 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/page/Page.resource.ts @@ -0,0 +1,339 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; + +import { + formatTitle, + getIconFromOptions, + getMarkdownUpdateBody, + getPageCreateContent, + getPageId, + getSearchSort, + handleOperationError, + simplifyObjects, +} from '../../helpers/utils'; +import { notionApiRequestAllItemsV3, notionApiRequestV3 } from '../../transport'; +import { + blockBuilder, + iconOptions, + pageLocator, + returnAllOrLimit, + searchOptions, +} from '../common.descriptions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['page'] } }, + options: [ + { + name: 'Archive', + value: 'archive', + description: 'Archive a page', + action: 'Archive a page', + }, + { name: 'Create', value: 'create', description: 'Create a page', action: 'Create a page' }, + { + name: 'Get Markdown', + value: 'getMarkdown', + description: 'Get page markdown', + action: 'Get page markdown', + }, + { name: 'Search', value: 'search', description: 'Search pages', action: 'Search a page' }, + { + name: 'Update Markdown', + value: 'updateMarkdown', + description: 'Update page markdown', + action: 'Update page markdown', + }, + ], + default: 'create', + }, + { + ...pageLocator, + displayName: 'Parent Page', + displayOptions: { show: { resource: ['page'], operation: ['create'] } }, + }, + { + ...pageLocator, + displayOptions: { + show: { resource: ['page'], operation: ['archive', 'getMarkdown', 'updateMarkdown'] }, + }, + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + required: true, + displayOptions: { show: { resource: ['page'], operation: ['create'] } }, + }, + { + displayName: 'Content Type', + name: 'contentType', + type: 'options', + options: [ + { name: 'Block Builder', value: 'blockUi' }, + { name: 'JSON Blocks', value: 'json' }, + { name: 'Markdown', value: 'markdown' }, + ], + default: 'blockUi', + displayOptions: { show: { resource: ['page'], operation: ['create'] } }, + }, + { + displayName: 'Blocks (JSON)', + name: 'blocksJson', + type: 'string', + typeOptions: { rows: 8 }, + default: '', + displayOptions: { show: { resource: ['page'], operation: ['create'], contentType: ['json'] } }, + }, + blockBuilder('page', 'create', { contentType: ['blockUi'] }), + { + displayName: 'Markdown', + name: 'markdown', + type: 'string', + typeOptions: { rows: 8 }, + default: '', + displayOptions: { + show: { resource: ['page'], operation: ['create'], contentType: ['markdown'] }, + }, + }, + iconOptions('page', ['create']), + { + displayName: 'Include Transcript', + name: 'includeTranscript', + type: 'boolean', + default: false, + displayOptions: { show: { resource: ['page'], operation: ['getMarkdown'] } }, + }, + { + displayName: 'Update Type', + name: 'markdownUpdateType', + type: 'options', + options: [ + { name: 'Replace Content', value: 'replace_content' }, + { name: 'Update Content', value: 'update_content' }, + ], + default: 'replace_content', + displayOptions: { show: { resource: ['page'], operation: ['updateMarkdown'] } }, + }, + { + displayName: 'Markdown', + name: 'markdown', + type: 'string', + typeOptions: { rows: 8 }, + default: '', + displayOptions: { + show: { + resource: ['page'], + operation: ['updateMarkdown'], + markdownUpdateType: ['replace_content'], + }, + }, + }, + { + displayName: 'Content Updates', + name: 'contentUpdates', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + placeholder: 'Add Update', + displayOptions: { + show: { + resource: ['page'], + operation: ['updateMarkdown'], + markdownUpdateType: ['update_content'], + }, + }, + options: [ + { + name: 'updates', + displayName: 'Update', + values: [ + { + displayName: 'Old String', + name: 'oldString', + type: 'string', + default: '', + description: 'Existing markdown content to find', + }, + { + displayName: 'New String', + name: 'newString', + type: 'string', + default: '', + description: 'Replacement markdown content', + }, + { + displayName: 'Replace All Matches', + name: 'replaceAllMatches', + type: 'boolean', + default: false, + description: 'Whether to replace all matches when old string appears multiple times', + }, + ], + }, + ], + }, + { + displayName: 'Search Text', + name: 'text', + type: 'string', + default: '', + displayOptions: { show: { resource: ['page'], operation: ['search'] } }, + }, + ...returnAllOrLimit('page', 'search'), + searchOptions('page', 'search'), + { + displayName: 'Simplify', + name: 'simple', + type: 'boolean', + default: true, + displayOptions: { show: { resource: ['page'], operation: ['archive', 'create', 'search'] } }, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, +]; + +export async function archive(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + let response: IDataObject | IDataObject[] = await notionApiRequestV3.call( + this, + 'PATCH', + `/pages/${getPageId.call(this, i)}`, + { in_trash: true }, + ); + if (this.getNodeParameter('simple', i) as boolean) + response = simplifyObjects(response, false, 3); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function create(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const body: IDataObject = { + parent: { page_id: getPageId.call(this, i) }, + properties: formatTitle(this.getNodeParameter('title', i) as string), + ...getPageCreateContent.call(this, i), + }; + const icon = getIconFromOptions.call(this, i); + if (icon) body.icon = icon; + let response: IDataObject | IDataObject[] = await notionApiRequestV3.call( + this, + 'POST', + '/pages', + body, + ); + if (this.getNodeParameter('simple', i) as boolean) + response = simplifyObjects(response, false, 3); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function getMarkdown(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const includeTranscript = this.getNodeParameter('includeTranscript', i) as boolean; + const response = await notionApiRequestV3.call( + this, + 'GET', + `/pages/${getPageId.call(this, i)}/markdown`, + {}, + includeTranscript ? { include_transcript: true } : {}, + ); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function search(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const body: IDataObject = { filter: { property: 'object', value: 'page' } }; + const text = this.getNodeParameter('text', i) as string; + if (text) body.query = text; + const sort = getSearchSort.call(this, i); + if (sort) body.sort = sort; + const returnAll = this.getNodeParameter('returnAll', i); + const limit = returnAll ? undefined : this.getNodeParameter('limit', i); + if (limit) body.page_size = Math.min(limit, 100); + let response: IDataObject[] = await notionApiRequestAllItemsV3.call( + this, + 'results', + 'POST', + '/search', + body, + limit ? { limit } : {}, + ); + if (this.getNodeParameter('simple', i) as boolean) + response = simplifyObjects(response, false, 3); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function updateMarkdown(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const response = await notionApiRequestV3.call( + this, + 'PATCH', + `/pages/${getPageId.call(this, i)}/markdown`, + getMarkdownUpdateBody.call(this, i), + ); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push.apply(returnData, executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/Notion/v3/actions/router.ts b/packages/nodes-base/nodes/Notion/v3/actions/router.ts new file mode 100644 index 00000000000..5b8af497d3a --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/router.ts @@ -0,0 +1,37 @@ +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import * as block from './block/Block.resource'; +import * as database from './database/Database.resource'; +import * as databasePage from './databasePage/DatabasePage.resource'; +import * as dataSource from './dataSource/DataSource.resource'; +import * as page from './page/Page.resource'; +import * as user from './user/User.resource'; + +const resources = { + block, + database, + databasePage, + dataSource, + page, + user, +}; + +type ResourceName = keyof typeof resources; + +export async function router(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const resource = this.getNodeParameter('resource', 0) as ResourceName; + const operation = this.getNodeParameter('operation', 0); + + const resourceRouter = resources[resource] as Record | undefined; + const execute = resourceRouter?.[operation as string]; + if (typeof execute !== 'function') { + throw new NodeOperationError( + this.getNode(), + `The operation "${operation}" is not supported for resource "${resource}"`, + ); + } + + return [await execute.call(this, items)]; +} diff --git a/packages/nodes-base/nodes/Notion/v3/actions/user/User.resource.ts b/packages/nodes-base/nodes/Notion/v3/actions/user/User.resource.ts new file mode 100644 index 00000000000..c94af1248df --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/actions/user/User.resource.ts @@ -0,0 +1,78 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { returnAllOrLimit } from '../common.descriptions'; +import { handleOperationError } from '../../helpers/utils'; +import { notionApiRequestAllItemsV3, notionApiRequestV3 } from '../../transport'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['user'] } }, + options: [ + { name: 'Get', value: 'get', description: 'Get a user', action: 'Get a user' }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many users', + action: 'Get many users', + }, + ], + default: 'get', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { show: { resource: ['user'], operation: ['get'] } }, + }, + ...returnAllOrLimit('user', 'getAll'), +]; + +export async function get(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const userId = this.getNodeParameter('userId', i) as string; + const response = await notionApiRequestV3.call(this, 'GET', `/users/${userId}`); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} + +export async function getAll(this: IExecuteFunctions, items: INodeExecutionData[]) { + const returnData: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + try { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const limit = returnAll ? undefined : (this.getNodeParameter('limit', i) as number); + const response = await notionApiRequestAllItemsV3.call( + this, + 'results', + 'GET', + '/users', + {}, + limit ? { limit } : {}, + ); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } catch (error) { + handleOperationError.call(this, returnData, error, i); + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/Notion/v3/helpers/utils.ts b/packages/nodes-base/nodes/Notion/v3/helpers/utils.ts new file mode 100644 index 00000000000..fbf6f002841 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/helpers/utils.ts @@ -0,0 +1,438 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeParameterResourceLocator, +} from 'n8n-workflow'; +import { jsonParse, NodeOperationError } from 'n8n-workflow'; + +import { blockUrlExtractionRegexp, databasePageUrlExtractionRegexp } from '../../shared/constants'; +import { + extractDatabaseMentionRLC, + extractPageId, + formatText, + formatTitle, + mapProperties, + mapSorting, + prepareNotionError, + simplifyBlocksOutput, + simplifyObjects, + validateJSON, + type SortData, +} from '../../shared/GenericFunctions'; +import { isDataObject } from '../transport'; + +export { + extractDatabaseMentionRLC, + formatTitle, + mapProperties, + mapSorting, + prepareNotionError, + simplifyBlocksOutput, + simplifyObjects, + validateJSON, + jsonParse, +}; +export type { SortData }; + +export function splitPropertyKey(key: string) { + const delimiterIndex = key.lastIndexOf('|'); + if (delimiterIndex === -1) return { name: key, type: '' }; + + return { + name: key.slice(0, delimiterIndex), + type: key.slice(delimiterIndex + 1), + }; +} + +function normalizePropertyValue(value: IDataObject) { + const normalizedValue: IDataObject = { ...value }; + + if (typeof normalizedValue.textContent === 'string') { + normalizedValue.richText = false; + } + + const isDateProperty = + normalizedValue.type === 'date' || + (typeof normalizedValue.key === 'string' && normalizedValue.key.endsWith('|date')); + if (isDateProperty) { + normalizedValue.range = normalizedValue.range ?? false; + normalizedValue.includeTime = normalizedValue.includeTime ?? true; + normalizedValue.timezone = normalizedValue.timezone ?? 'default'; + + if (!normalizedValue.range) { + delete normalizedValue.dateStart; + delete normalizedValue.dateEnd; + } else { + delete normalizedValue.date; + } + } + + return normalizedValue; +} + +export function normalizePropertyValues(values: IDataObject[]) { + return values.map(normalizePropertyValue); +} + +export function normalizeBlockValues(values: IDataObject[]) { + return values.map((value) => { + if (typeof value.textContent !== 'string') return value; + return { + ...value, + richText: false, + }; + }); +} + +type RichTextEntry = IDataObject & { + annotationUi?: IDataObject; + date?: string; + dateEnd?: string; + dateStart?: string; + expression?: string; + isLink?: boolean; + mentionType?: string; + range?: boolean; + text?: string; + textLink?: string; + textType?: string; +}; + +function getRichTextAnnotations(entry: RichTextEntry) { + return isDataObject(entry.annotationUi) ? { annotations: entry.annotationUi } : {}; +} + +function getTextLink(entry: RichTextEntry) { + if (entry.isLink === true && typeof entry.textLink === 'string' && entry.textLink !== '') { + return { link: { url: entry.textLink } }; + } + return {}; +} + +function getMentionId(value: unknown) { + if (typeof value === 'string') return value; + if (isDataObject(value) && typeof value.value === 'string') return value.value; + return undefined; +} + +function formatMention(entry: RichTextEntry) { + const mentionType = entry.mentionType; + if (typeof mentionType !== 'string') return undefined; + + if (mentionType === 'date') { + return { + type: 'mention', + mention: { + type: 'date', + date: + entry.range === true + ? { start: entry.dateStart, end: entry.dateEnd } + : { start: entry.date, end: null }, + }, + ...getRichTextAnnotations(entry), + }; + } + + const mentionId = getMentionId(entry[mentionType]); + if (!mentionId) return undefined; + + return { + type: 'mention', + mention: { + type: mentionType, + [mentionType]: { id: mentionId }, + }, + ...getRichTextAnnotations(entry), + }; +} + +const parseText = (value: unknown): string => { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + throw new Error(`Text value must be a string, number, or boolean. Received: ${typeof value}`); +}; + +function formatRichText(values: unknown) { + if (!Array.isArray(values)) return []; + + const results: IDataObject[] = []; + for (const value of values) { + if (!isDataObject(value)) continue; + + const entry = value as RichTextEntry; + if (entry.textType === 'text') { + results.push({ + type: 'text', + text: { + content: parseText(entry.text), + ...getTextLink(entry), + }, + ...getRichTextAnnotations(entry), + }); + } else if (entry.textType === 'mention') { + const mention = formatMention(entry); + if (mention) results.push(mention); + } else if (entry.textType === 'equation') { + results.push({ + type: 'equation', + equation: { + expression: parseText(entry.expression), + }, + ...getRichTextAnnotations(entry), + }); + } + } + + return results; +} + +export function formatBlocks(blockValues: IDataObject[]) { + const results: IDataObject[] = []; + + for (const block of blockValues) { + const blockType = block.type; + if (typeof blockType !== 'string') continue; + + const blockBody: IDataObject = {}; + + const prepareText = () => { + // rich text uses `text` parameter + if (block.richText === true && isDataObject(block.text)) { + blockBody.rich_text = formatRichText(block.text.text); + } else { + // regular text uses `textContent` + const textContent = parseText(block.textContent); + blockBody.rich_text = formatText(textContent).text; + } + }; + switch (blockType) { + case 'to_do': + blockBody.checked = block.checked; + prepareText(); + break; + case 'child_page': + blockBody.title = block.title; + break; + case 'image': + blockBody.type = 'external'; + blockBody.external = { url: block.url }; + break; + // other block types such as paragraph, heading, etc. don't need special handling + default: + prepareText(); + break; + } + + results.push({ + object: 'block', + type: blockType, + [blockType]: blockBody, + }); + } + + return results; +} + +export function parseJsonParameter(this: IExecuteFunctions, value: string, itemIndex: number) { + try { + return JSON.parse(value) as unknown; + } catch { + throw new NodeOperationError(this.getNode(), 'JSON value must be valid JSON', { itemIndex }); + } +} + +function getUrlSearchParam(value: string, parameterName: string) { + try { + return new URL(value).searchParams.get(parameterName) ?? ''; + } catch { + return ''; + } +} + +function getUrlHash(value: string) { + try { + return new URL(value).hash.slice(1); + } catch { + return ''; + } +} + +export function getPageId(this: IExecuteFunctions, itemIndex: number) { + const page = this.getNodeParameter('pageId', itemIndex, {}) as INodeParameterResourceLocator; + let pageId = ''; + + if (page.value && typeof page.value === 'string') { + if (page.mode === 'id') { + pageId = page.value; + } else if (page.value.includes('p=')) { + pageId = getUrlSearchParam(page.value, 'p'); + } else { + pageId = page.value.match(databasePageUrlExtractionRegexp)?.[1] ?? ''; + } + } + + if (!pageId) { + throw new NodeOperationError( + this.getNode(), + `Could not extract page ID from URL: ${page.value}`, + ); + } + + return pageId; +} + +export function extractBlockId(this: IExecuteFunctions, itemIndex: number) { + const blockIdRLCData = this.getNodeParameter('blockId', itemIndex, {}) as IDataObject; + + if (blockIdRLCData.mode === 'id') { + return blockIdRLCData.value as string; + } + + const blockUrl = blockIdRLCData.value as string; + const hashId = getUrlHash(blockUrl); + if (hashId) { + return extractPageId(hashId); + } + + const pageRegex = new RegExp(blockUrlExtractionRegexp); + const pageMatch = blockUrl.match(pageRegex); + if (pageMatch !== null) { + return extractPageId(pageMatch[1]); + } + + throw new NodeOperationError(this.getNode(), 'Invalid URL, could not find block ID or page ID', { + itemIndex, + }); +} + +export function getJsonBlocks(this: IExecuteFunctions, itemIndex: number) { + const blocksJson = this.getNodeParameter('blocksJson', itemIndex, '') as string; + if (!blocksJson) return []; + + const parsed = parseJsonParameter.call(this, blocksJson, itemIndex); + if (!Array.isArray(parsed)) { + throw new NodeOperationError( + this.getNode(), + 'Blocks (JSON) must be an array of Notion block objects', + { itemIndex }, + ); + } + return parsed.filter(isDataObject); +} + +export function getPageCreateContent(this: IExecuteFunctions, itemIndex: number) { + const contentType = this.getNodeParameter('contentType', itemIndex, 'blockUi') as string; + if (contentType === 'json') { + return { children: getJsonBlocks.call(this, itemIndex) }; + } + if (contentType === 'markdown') { + return { markdown: this.getNodeParameter('markdown', itemIndex, '') as string }; + } + + const blockValues = normalizeBlockValues( + this.getNodeParameter('blockUi.blockValues', itemIndex, []) as IDataObject[], + ); + extractDatabaseMentionRLC(blockValues); + return { children: formatBlocks(blockValues) }; +} + +export function getIconFromOptions(this: IExecuteFunctions, itemIndex: number) { + const options = this.getNodeParameter('options', itemIndex, {}); + const icon = options.icon; + if (typeof icon !== 'string' || icon === '') return undefined; + + let isUrl = false; + try { + const url = new URL(icon); + isUrl = url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + isUrl = false; + } + + if (isUrl) { + return { type: 'external', external: { url: icon } }; + } + + return { type: 'emoji', emoji: icon }; +} + +export function getSearchSort(this: IExecuteFunctions, itemIndex: number) { + const sort: unknown = this.getNodeParameter('options.sort.sortValue', itemIndex, {}); + const sortValue: unknown = Array.isArray(sort) ? (sort as unknown[])[0] : sort; + if (!isDataObject(sortValue)) return undefined; + + const { direction, timestamp } = sortValue; + if (typeof direction !== 'string' || typeof timestamp !== 'string') return undefined; + + return { direction, timestamp }; +} + +export function getMarkdownUpdateBody(this: IExecuteFunctions, itemIndex: number) { + const type = this.getNodeParameter('markdownUpdateType', itemIndex) as string; + if (type === 'replace_content') { + return { + type, + replace_content: { + new_str: this.getNodeParameter('markdown', itemIndex) as string, + }, + }; + } + + const updates = ( + (this.getNodeParameter('contentUpdates.updates', itemIndex, []) as IDataObject[] | undefined) ?? + [] + ).filter(isDataObject); + return { + type, + update_content: { + content_updates: updates.map((update) => ({ + old_str: update.oldString, + new_str: update.newString, + ...(update.replaceAllMatches ? { replace_all_matches: true } : {}), + })), + }, + }; +} + +export function flattenDataSources(searchResults: IDataObject[]) { + const dataSources: IDataObject[] = []; + for (const result of searchResults) { + if (result.object === 'data_source') { + dataSources.push(result); + continue; + } + if (Array.isArray(result.data_sources)) { + for (const dataSource of result.data_sources) { + if (isDataObject(dataSource)) { + dataSources.push({ + ...dataSource, + database_id: result.id, + database_url: result.url, + }); + } + } + } + } + return dataSources; +} + +export function handleOperationError( + this: IExecuteFunctions, + returnData: INodeExecutionData[], + error: unknown, + itemIndex: number, +) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + const preparedError = prepareNotionError(this.getNode(), normalizedError, itemIndex); + + if (this.continueOnFail()) { + returnData.push({ + json: { error: preparedError.message }, + pairedItem: { item: itemIndex }, + }); + return; + } + + throw preparedError; +} diff --git a/packages/nodes-base/nodes/Notion/v3/methods/index.ts b/packages/nodes-base/nodes/Notion/v3/methods/index.ts new file mode 100644 index 00000000000..45b0773eeff --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/methods/index.ts @@ -0,0 +1,4 @@ +import * as loadOptions from './loadOptions'; +import * as listSearch from './listSearch'; + +export { listSearch, loadOptions }; diff --git a/packages/nodes-base/nodes/Notion/v3/methods/listSearch.ts b/packages/nodes-base/nodes/Notion/v3/methods/listSearch.ts new file mode 100644 index 00000000000..a976f5ad80e --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/methods/listSearch.ts @@ -0,0 +1,90 @@ +import type { + IDataObject, + ILoadOptionsFunctions, + INodeListSearchItems, + INodeListSearchResult, +} from 'n8n-workflow'; + +import { isDataObject, notionApiRequestAllItemsV3 } from '../transport'; + +function getStringProperty(data: IDataObject, propertyName: string) { + const value: unknown = data[propertyName]; + return typeof value === 'string' ? value : undefined; +} + +function getPlainTextTitle(database: IDataObject) { + const title: unknown = database.title; + const id = getStringProperty(database, 'id') ?? ''; + if (!Array.isArray(title)) return id; + const plainText = title + .filter(isDataObject) + .map((titlePart) => getStringProperty(titlePart, 'plain_text') ?? '') + .join(''); + return plainText || id; +} + +function getDataSourceName(dataSource: IDataObject) { + const name = getStringProperty(dataSource, 'name'); + if (name) return name; + return getPlainTextTitle(dataSource); +} + +function getParentDatabaseId(dataSource: IDataObject) { + const parent: unknown = dataSource.parent; + if (!isDataObject(parent)) return undefined; + return getStringProperty(parent, 'database_id'); +} + +async function searchDataSources(this: ILoadOptionsFunctions, filter?: string) { + const body: IDataObject = { + page_size: 100, + query: filter, + filter: { property: 'object', value: 'data_source' }, + }; + return await notionApiRequestAllItemsV3.call(this, 'results', 'POST', '/search', body); +} + +export async function getDataSources( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const dataSources = await searchDataSources.call(this, filter); + const returnData: INodeListSearchItems[] = []; + + for (const dataSource of dataSources) { + if (!isDataObject(dataSource)) continue; + + returnData.push({ + name: getDataSourceName(dataSource), + value: getStringProperty(dataSource, 'id') ?? '', + url: getStringProperty(dataSource, 'url'), + }); + } + + returnData.sort((a, b) => a.name.localeCompare(b.name)); + return { results: returnData }; +} + +export async function getDatabases( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const dataSources = await searchDataSources.call(this, filter); + const databasesById = new Map(); + + for (const dataSource of dataSources) { + if (!isDataObject(dataSource)) continue; + const databaseId = getParentDatabaseId(dataSource); + if (!databaseId || databasesById.has(databaseId)) continue; + + databasesById.set(databaseId, { + name: getPlainTextTitle(dataSource), + value: databaseId, + url: getStringProperty(dataSource, 'url'), + }); + } + + const returnData = [...databasesById.values()]; + returnData.sort((a, b) => a.name.localeCompare(b.name)); + return { results: returnData }; +} diff --git a/packages/nodes-base/nodes/Notion/v3/methods/loadOptions.ts b/packages/nodes-base/nodes/Notion/v3/methods/loadOptions.ts new file mode 100644 index 00000000000..360fc4e7f6e --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/methods/loadOptions.ts @@ -0,0 +1,188 @@ +import moment from 'moment-timezone'; +import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; + +import { extractPageId, getBlockTypesOptions } from '../../shared/GenericFunctions'; +import { splitPropertyKey } from '../helpers/utils'; +import { + getDataSourceProperties, + isDataObject, + notionApiRequestAllItemsV3, + notionApiRequestV3, +} from '../transport'; + +const READ_ONLY_PROPERTY_TYPES = [ + 'created_time', + 'last_edited_time', + 'created_by', + 'last_edited_by', + 'formula', + 'rollup', +]; + +async function getSelectedDataSourceProperties( + this: ILoadOptionsFunctions, + parameterName = 'dataSourceId', +) { + const dataSourceId = this.getCurrentNodeParameter(parameterName, { + extractValue: true, + }) as string; + + if (!dataSourceId) { + throw new Error('No data source ID selected'); + } + + return await getDataSourceProperties.call(this, dataSourceId); +} + +function mapPropertiesToOptions( + properties: IDataObject | undefined, + options: { includeReadOnly: boolean }, +) { + if (!properties) { + return []; + } + + const returnData: INodePropertyOptions[] = []; + for (const key of Object.keys(properties)) { + const property = properties[key]; + if (!isDataObject(property) || typeof property.type !== 'string') continue; + if (!options.includeReadOnly && READ_ONLY_PROPERTY_TYPES.includes(property.type)) continue; + + returnData.push({ + name: key, + value: `${key}|${property.type}`, + }); + } + return returnData.sort((a, b) => a.name.localeCompare(b.name)); +} + +function mapSelectOptions(options: unknown[]): INodePropertyOptions[] { + return options.filter(isDataObject).flatMap((option) => { + if (typeof option.name !== 'string') return []; + + return { + name: option.name, + value: option.name, + }; + }); +} + +export async function getDataSourcePropertiesOptions( + this: ILoadOptionsFunctions, +): Promise { + const properties = await getSelectedDataSourceProperties.call(this); + return mapPropertiesToOptions(properties, { includeReadOnly: false }); +} + +export async function getFilterProperties( + this: ILoadOptionsFunctions, +): Promise { + const properties = await getSelectedDataSourceProperties.call(this); + return mapPropertiesToOptions(properties, { includeReadOnly: true }); +} + +export async function getBlockTypes(this: ILoadOptionsFunctions): Promise { + return getBlockTypesOptions(); +} + +export async function getPropertySelectValues( + this: ILoadOptionsFunctions, +): Promise { + const key = this.getCurrentNodeParameter('&key') as string | undefined; + if (!key) { + return []; + } + const { name, type } = splitPropertyKey(key); + const properties = await getSelectedDataSourceProperties.call(this); + const property = properties?.[name]; + if ( + !isDataObject(property) || + !isDataObject(property[type]) || + !Array.isArray(property[type].options) + ) { + return []; + } + + return mapSelectOptions(property[type].options); +} + +export async function getUsers(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const users = await notionApiRequestAllItemsV3.call(this, 'results', 'GET', '/users'); + for (const user of users) { + if (isDataObject(user) && user.type === 'person') { + returnData.push({ + name: user.name as string, + value: user.id as string, + }); + } + } + return returnData; +} + +async function getParentDataSourceIdFromPage( + this: ILoadOptionsFunctions, +): Promise { + const pageId = extractPageId( + this.getCurrentNodeParameter('pageId', { extractValue: true }) as string, + ); + const page = await notionApiRequestV3.call(this, 'GET', `/pages/${pageId}`); + if (!isDataObject(page) || !isDataObject(page.parent)) { + return undefined; + } + + const parent = page.parent; + return typeof parent.data_source_id === 'string' ? parent.data_source_id : undefined; +} + +export async function getDataSourcePropertiesFromPage( + this: ILoadOptionsFunctions, +): Promise { + const dataSourceId = await getParentDataSourceIdFromPage.call(this); + + if (!dataSourceId) { + return []; + } + + const properties = await getDataSourceProperties.call(this, dataSourceId); + return mapPropertiesToOptions(properties, { includeReadOnly: false }); +} + +export async function getDataSourceOptionsFromPage( + this: ILoadOptionsFunctions, +): Promise { + const key = this.getCurrentNodeParameter('&key') as string | undefined; + if (!key) { + return []; + } + const { name, type } = splitPropertyKey(key); + const dataSourceId = await getParentDataSourceIdFromPage.call(this); + if (!dataSourceId) return []; + + const properties = await getDataSourceProperties.call(this, dataSourceId); + const property = properties[name]; + if ( + !isDataObject(property) || + !isDataObject(property[type]) || + !Array.isArray(property[type].options) + ) { + return []; + } + return mapSelectOptions(property[type].options); +} + +export async function getTimezones(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + for (const timezone of moment.tz.names()) { + returnData.push({ + name: timezone, + value: timezone, + }); + } + returnData.unshift({ + name: 'Default', + value: 'default', + description: 'Timezone set in n8n', + }); + return returnData; +} diff --git a/packages/nodes-base/nodes/Notion/v3/transport/index.ts b/packages/nodes-base/nodes/Notion/v3/transport/index.ts new file mode 100644 index 00000000000..9418452abf1 --- /dev/null +++ b/packages/nodes-base/nodes/Notion/v3/transport/index.ts @@ -0,0 +1,94 @@ +import type { + IDataObject, + IExecuteFunctions, + IHttpRequestMethods, + IHttpRequestOptions, + ILoadOptionsFunctions, + IPollFunctions, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; + +type NotionFunctions = IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions; + +const NOTION_VERSION_HEADER = 'Notion-Version'; +const NOTION_API_VERSION = '2026-03-11'; + +export function isDataObject(value: unknown): value is IDataObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export async function notionApiRequestV3( + this: NotionFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, +): Promise { + try { + const options: IHttpRequestOptions = { + method, + qs, + body, + url: `https://api.notion.com/v1${resource}`, + json: true, + headers: { + [NOTION_VERSION_HEADER]: NOTION_API_VERSION, + }, + }; + if (Object.keys(body).length === 0) { + delete options.body; + } + const authentication = this.getNodeParameter('authentication', 0, 'apiKey') as string; + const credentialType = authentication === 'oAuth2' ? 'notionOAuth2Api' : 'notionApi'; + return (await this.helpers.httpRequestWithAuthentication.call( + this, + credentialType, + options, + )) as IDataObject; + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +export async function notionApiRequestAllItemsV3( + this: NotionFunctions, + propertyName: string, + method: IHttpRequestMethods, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, +): Promise { + const limit = query.limit as number | undefined; + delete query.limit; + + const returnData: IDataObject[] = []; + let responseData: IDataObject; + + do { + responseData = await notionApiRequestV3.call(this, method, endpoint, body, query); + const nextCursor = responseData.next_cursor; + if (method === 'GET') { + query.start_cursor = nextCursor; + } else { + body.start_cursor = nextCursor; + } + const page = responseData[propertyName]; + if (Array.isArray(page)) { + returnData.push.apply(returnData, page.filter(isDataObject)); + } + if (limit && limit <= returnData.length) { + return returnData.slice(0, limit); + } + } while (responseData.has_more !== false); + + return limit ? returnData.slice(0, limit) : returnData; +} + +export async function getDataSourceProperties(this: NotionFunctions, dataSourceId: string) { + const dataSource = await notionApiRequestV3.call(this, 'GET', `/data_sources/${dataSourceId}`); + if (!isDataObject(dataSource.properties)) { + throw new NodeOperationError(this.getNode(), 'Notion did not return data source properties'); + } + return dataSource.properties; +}