feat(Notion Node): Migrate to new API and overhaul the node (#33749)

This commit is contained in:
yehorkardash
2026-07-09 13:45:57 +02:00
committed by GitHub
parent 2d510e6820
commit 276752f9c5
46 changed files with 5960 additions and 324 deletions
@@ -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);
@@ -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<NotionQueryResponse>;
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<INodeExecutionData[][] | null> {
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
@@ -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
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -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
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"version": 1
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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<string, { key: string; [k: string]: any }> =>
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;
}
}
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>',
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),
],
},
],
},
];
};
@@ -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<typeof Transport>('../v3/transport')),
notionApiRequestV3: vi.fn(),
}));
const mockNotionApiRequest = GenericFunctions.notionApiRequest as Mock;
const mockNotionApiRequestV3 = Transport.notionApiRequestV3 as Mock;
function createPollContext(
staticData: Record<string, unknown> = {},
mode: 'trigger' | 'manual' = 'trigger',
typeVersion = 1.1,
) {
return {
getWorkflowStaticData: vi.fn().mockReturnValue(staticData),
getNodeParameter: vi.fn().mockImplementation((name: string) => {
const params: Record<string, unknown> = {
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {
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<string, unknown> = {};
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');
File diff suppressed because it is too large Load Diff
@@ -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<typeof Transport>('../../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',
},
]);
});
});
@@ -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<typeof Transport>('../../v3/transport')),
getDataSourceProperties: vi.fn(),
notionApiRequestV3: vi.fn(),
}));
const mockGetDataSourceProperties = Transport.getDataSourceProperties as Mock;
const mockNotionApiRequest = Transport.notionApiRequestV3 as Mock;
function createLoadOptionsContext(parameters: Record<string, unknown>): 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();
});
});
@@ -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',
}),
}),
);
});
});
@@ -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' } }],
},
},
]);
});
});
});
@@ -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}`);
@@ -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}`);
@@ -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);
}
}
@@ -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 <a href="https://www.notion.com/help/add-and-manage-connections-with-the-api" target="_blank">add your connection</a> 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,
],
};
@@ -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;
}
@@ -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<string, string[]> = {
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',
});
@@ -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;
}
@@ -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;
}
@@ -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<string, string[]> = {
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<string, string> = {
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
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 <a href="https://developers.notion.com/reference/filter-data-source-entries" target="_blank">Notion guide</a> 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'] },
},
},
];
}
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>. Use the format <code>Property Name|property_type</code>, for example <code>Due Date|date</code>.';
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
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;
}
@@ -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' };
@@ -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;
}
@@ -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<INodeExecutionData[][]> {
const items = this.getInputData();
const resource = this.getNodeParameter('resource', 0) as ResourceName;
const operation = this.getNodeParameter('operation', 0);
const resourceRouter = resources[resource] as Record<string, unknown> | 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)];
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,4 @@
import * as loadOptions from './loadOptions';
import * as listSearch from './listSearch';
export { listSearch, loadOptions };
@@ -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<INodeListSearchResult> {
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<INodeListSearchResult> {
const dataSources = await searchDataSources.call(this, filter);
const databasesById = new Map<string, INodeListSearchItems>();
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 };
}
@@ -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<INodePropertyOptions[]> {
const properties = await getSelectedDataSourceProperties.call(this);
return mapPropertiesToOptions(properties, { includeReadOnly: false });
}
export async function getFilterProperties(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const properties = await getSelectedDataSourceProperties.call(this);
return mapPropertiesToOptions(properties, { includeReadOnly: true });
}
export async function getBlockTypes(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
return getBlockTypesOptions();
}
export async function getPropertySelectValues(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
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<string | undefined> {
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<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
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;
}
@@ -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<IDataObject> {
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<IDataObject[]> {
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;
}