mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(Notion Node): Migrate to new API and overhaul the node (#33749)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user