From a144a8e315b6f619d5651495e16fa4f480adb994 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 29 Sep 2021 19:28:27 -0400 Subject: [PATCH] :sparkles: Add SeaTable node and trigger (#2240) * Add SeaTable node Node for SeaTable, initial credentials, trigger- and standard-node. Contribution-by: SeaTable GmbH Signed-off-by: Tom Klingenberg * :zap: Improvements * :zap: Improvements * :zap: Fix node and method names and table parameter * :zap: Change display name for now again Co-authored-by: Tom Klingenberg Co-authored-by: Jan Oberhauser --- .../credentials/SeaTableApi.credentials.ts | 48 +++ .../nodes/SeaTable/GenericFunctions.ts | 294 +++++++++++++++ .../nodes-base/nodes/SeaTable/Interfaces.ts | 102 +++++ .../nodes/SeaTable/RowDescription.ts | 348 ++++++++++++++++++ packages/nodes-base/nodes/SeaTable/Schema.ts | 49 +++ .../nodes/SeaTable/SeaTable.node.json | 72 ++++ .../nodes/SeaTable/SeaTable.node.ts | 319 ++++++++++++++++ .../nodes/SeaTable/SeaTableTrigger.node.json | 20 + .../nodes/SeaTable/SeaTableTrigger.node.ts | 158 ++++++++ .../nodes-base/nodes/SeaTable/seaTable.svg | 1 + packages/nodes-base/nodes/SeaTable/types.d.ts | 69 ++++ packages/nodes-base/package.json | 3 + 12 files changed, 1483 insertions(+) create mode 100644 packages/nodes-base/credentials/SeaTableApi.credentials.ts create mode 100644 packages/nodes-base/nodes/SeaTable/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/SeaTable/Interfaces.ts create mode 100644 packages/nodes-base/nodes/SeaTable/RowDescription.ts create mode 100644 packages/nodes-base/nodes/SeaTable/Schema.ts create mode 100644 packages/nodes-base/nodes/SeaTable/SeaTable.node.json create mode 100644 packages/nodes-base/nodes/SeaTable/SeaTable.node.ts create mode 100644 packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json create mode 100644 packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts create mode 100644 packages/nodes-base/nodes/SeaTable/seaTable.svg create mode 100644 packages/nodes-base/nodes/SeaTable/types.d.ts diff --git a/packages/nodes-base/credentials/SeaTableApi.credentials.ts b/packages/nodes-base/credentials/SeaTableApi.credentials.ts new file mode 100644 index 0000000000..e521cc6e0c --- /dev/null +++ b/packages/nodes-base/credentials/SeaTableApi.credentials.ts @@ -0,0 +1,48 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class SeaTableApi implements ICredentialType { + name = 'seaTableApi'; + displayName = 'SeaTable API'; + documentationUrl = 'seaTable'; + properties: INodeProperties[] = [ + { + displayName: 'Environment', + name: 'environment', + type: 'options', + default: 'cloudHosted', + options: [ + { + name: 'Cloud-hosted', + value: 'cloudHosted', + }, + { + name: 'Self-hosted', + value: 'selfHosted', + }, + ], + }, + { + displayName: 'Self-hosted domain', + name: 'domain', + type: 'string', + default: '', + placeholder: 'https://www.mydomain.com', + displayOptions: { + show: { + environment: [ + 'selfHosted', + ], + }, + }, + }, + { + displayName: 'API Token (of a Base)', + name: 'token', + type: 'string', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts b/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts new file mode 100644 index 0000000000..267e9390f1 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts @@ -0,0 +1,294 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + OptionsWithUri, +} from 'request'; + +import { + IDataObject, + ILoadOptionsFunctions, + IPollFunctions, + NodeApiError, +} from 'n8n-workflow'; + +import { + TDtableMetadataColumns, + TDtableViewColumns, + TEndpointResolvedExpr, + TEndpointVariableName, +} from './types'; + +import { + schema, +} from './Schema'; + +import { + ICredential, + ICtx, + IDtableMetadataColumn, + IEndpointVariables, + IName, + IRow, + IRowObject, +} from './Interfaces'; + +import * as _ from 'lodash'; + +export async function seaTableApiRequest(this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, ctx: ICtx, method: string, endpoint: string, body: any = {}, qs: IDataObject = {}, url: string | undefined = undefined, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const credentials = await this.getCredentials('seaTableApi'); + + ctx.credentials = credentials as unknown as ICredential; + + await getBaseAccessToken.call(this, ctx); + + const options: OptionsWithUri = { + headers: { + Authorization: `Token ${ctx?.base?.access_token}`, + }, + method, + qs, + body, + uri: url || `${resolveBaseUri(ctx)}${endpointCtxExpr(ctx, endpoint)}`, + json: true, + }; + + if (Object.keys(body).length === 0) { + delete options.body; + } + + if (Object.keys(option).length !== 0) { + Object.assign(options, option); + } + + try { + //@ts-ignore + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function setableApiRequestAllItems(this: IExecuteFunctions | IPollFunctions, ctx: ICtx, propertyName: string, method: string, endpoint: string, body: IDataObject, query?: IDataObject): Promise { // tslint:disable-line:no-any + + if (query === undefined) { + query = {}; + } + const segment = schema.rowFetchSegmentLimit; + query.start = 0; + query.limit = segment; + + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await seaTableApiRequest.call(this, ctx, method, endpoint, body, query) as unknown as IRow[]; + //@ts-ignore + returnData.push.apply(returnData, responseData[propertyName]); + query.start = +query.start + segment; + } while (responseData && responseData.length > segment - 1); + + return returnData; +} + + +export async function getTableColumns(this: ILoadOptionsFunctions | IExecuteFunctions | IPollFunctions, tableName: string, ctx: ICtx = {}): Promise { + const { metadata: { tables } } = await seaTableApiRequest.call(this, ctx, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata`); + for (const table of tables) { + if (table.name === tableName) { + return table.columns; + } + } + return []; +} + +export async function getTableViews(this: ILoadOptionsFunctions | IExecuteFunctions, tableName: string, ctx: ICtx = {}): Promise { + const { views } = await seaTableApiRequest.call(this, ctx, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/views`, {}, { table_name: tableName }); + return views; +} + +export async function getBaseAccessToken(this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, ctx: ICtx) { + + if (ctx?.base?.access_token !== undefined) { + return; + } + + const options: OptionsWithUri = { + headers: { + Authorization: `Token ${ctx?.credentials?.token}`, + }, + uri: `${resolveBaseUri(ctx)}/api/v2.1/dtable/app-access-token/`, + json: true, + }; + + ctx.base = await this.helpers.request!(options); +} + +export function resolveBaseUri(ctx: ICtx) { + return (ctx?.credentials?.environment === 'cloudHosted') + ? 'https://cloud.seatable.io' : ctx?.credentials?.domain; +} + +export function simplify(data: { results: IRow[] }, metadata: IDataObject) { + return data.results.map((row: IDataObject) => { + for (const key of Object.keys(row)) { + if (!key.startsWith('_')) { + row[metadata[key] as string] = row[key]; + delete row[key]; + } + } + return row; + }); +} + +export function getColumns(data: { metadata: [{ key: string, name: string }] }) { + return data.metadata.reduce((obj, value) => Object.assign(obj, { [`${value.key}`]: value.name }), {}); +} + +export function getDownloadableColumns(data: { metadata: [{ key: string, name: string, type: string }] }) { + return data.metadata.filter(row => (['image', 'file'].includes(row.type))).map(row => row.name); +} + +const uniquePredicate = (current: string, index: number, all: string[]) => all.indexOf(current) === index; +const nonInternalPredicate = (name: string) => !Object.keys(schema.internalNames).includes(name); +const namePredicate = (name: string) => (named: IName) => named.name === name; +export const nameOfPredicate = (names: ReadonlyArray) => (name: string) => names.find(namePredicate(name)); + +export function columnNamesToArray(columnNames: string): string[] { + return columnNames + ? split(columnNames) + .filter(nonInternalPredicate) + .filter(uniquePredicate) + : [] + ; +} + +export function columnNamesGlob(columnNames: string[], dtableColumns: TDtableMetadataColumns): string[] { + const buffer: string[] = []; + const names: string[] = dtableColumns.map(c => c.name).filter(nonInternalPredicate); + columnNames.forEach(columnName => { + if (columnName !== '*') { + buffer.push(columnName); + return; + } + buffer.push(...names); + }); + return buffer.filter(uniquePredicate); +} + +/** + * sequence rows on _seq + */ +export function rowsSequence(rows: IRow[]) { + const l = rows.length; + if (l) { + const [first] = rows; + if (first && first._seq !== undefined) { + return; + } + } + for (let i = 0; i < l;) { + rows[i]._seq = ++i; + } +} + +export function rowDeleteInternalColumns(row: IRow): IRow { + Object.keys(schema.internalNames).forEach(columnName => delete row[columnName]); + return row; +} + +export function rowsDeleteInternalColumns(rows: IRow[]) { + rows = rows.map(rowDeleteInternalColumns); +} + +function rowFormatColumn(input: unknown): boolean | number | string | string[] | null { + if (null === input || undefined === input) { + return null; + } + + if (typeof input === 'boolean' || typeof input === 'number' || typeof input === 'string') { + return input; + } + + if (Array.isArray(input) && input.every(i => (typeof i === 'string'))) { + return input; + } + + return null; +} + +export function rowFormatColumns(row: IRow, columnNames: string[]): IRow { + const outRow = {} as IRow; + columnNames.forEach((c) => (outRow[c] = rowFormatColumn(row[c]))); + return outRow; +} + +export function rowsFormatColumns(rows: IRow[], columnNames: string[]) { + rows = rows.map((row) => rowFormatColumns(row, columnNames)); +} + +export function rowMapKeyToName(row: IRow, columns: TDtableMetadataColumns): IRow { + const mappedRow = {} as IRow; + + // move internal columns first + Object.keys(schema.internalNames).forEach((key) => { + if (row[key]) { + mappedRow[key] = row[key]; + delete row[key]; + } + }); + + // pick each by its key for name + Object.keys(row).forEach(key => { + const column = columns.find(c => c.key === key); + if (column) { + mappedRow[column.name] = row[key]; + } + }); + + return mappedRow; +} + +export function rowExport(row: IRowObject, columns: TDtableMetadataColumns): IRowObject { + for (const columnName of Object.keys(columns)) { + if (!columns.find(namePredicate(columnName))) { + delete row[columnName]; + } + } + return row; +} + +export const dtableSchemaIsColumn = (column: IDtableMetadataColumn): boolean => + !!schema.columnTypes[column.type]; + +const dtableSchemaIsUpdateAbleColumn = (column: IDtableMetadataColumn): boolean => + !!schema.columnTypes[column.type] && !schema.nonUpdateAbleColumnTypes[column.type]; + +export const dtableSchemaColumns = (columns: TDtableMetadataColumns): TDtableMetadataColumns => + columns.filter(dtableSchemaIsColumn); + +export const updateAble = (columns: TDtableMetadataColumns): TDtableMetadataColumns => + columns.filter(dtableSchemaIsUpdateAbleColumn); + +function endpointCtxExpr(this: void, ctx: ICtx, endpoint: string): string { + const endpointVariables: IEndpointVariables = {}; + endpointVariables.access_token = ctx?.base?.access_token; + endpointVariables.dtable_uuid = ctx?.base?.dtable_uuid; + + return endpoint.replace(/({{ *(access_token|dtable_uuid|server) *}})/g, (match: string, expr: string, name: TEndpointVariableName) => { + return endpointVariables[name] || match; + }) as TEndpointResolvedExpr; +} + + +const normalize = (subject: string): string => subject ? subject.normalize() : ''; + +export const split = (subject: string): string[] => + normalize(subject) + .split(/\s*((?:[^\\,]*?(?:\\[\s\S])*)*?)\s*(?:,|$)/) + .filter(s => s.length) + .map(s => s.replace(/\\([\s\S])/gm, ($0, $1) => $1)) + ; diff --git a/packages/nodes-base/nodes/SeaTable/Interfaces.ts b/packages/nodes-base/nodes/SeaTable/Interfaces.ts new file mode 100644 index 0000000000..8d8357476b --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/Interfaces.ts @@ -0,0 +1,102 @@ +import { + TColumnType, + TColumnValue, + TDtableMetadataColumns, + TDtableMetadataTables, + TSeaTableServerEdition, + TSeaTableServerVersion, +} from './types'; + +export interface IApi { + server: string; + token: string; + appAccessToken?: IAppAccessToken; + info?: IServerInfo; +} + +export interface IServerInfo { + version: TSeaTableServerVersion; + edition: TSeaTableServerEdition; +} + +export interface IAppAccessToken { + app_name: string; + access_token: string; + dtable_uuid: string; + dtable_server: string; + dtable_socket: string; + workspace_id: number; + dtable_name: string; +} + +export interface IDtableMetadataColumn { + key: string; + name: string; + type: TColumnType; + editable: boolean; +} + +export interface TDtableViewColumn { + _id: string; + name: string; +} + +export interface IDtableMetadataTable { + _id: string; + name: string; + columns: TDtableMetadataColumns; +} + +export interface IDtableMetadata { + tables: TDtableMetadataTables; + version: string; + format_version: string; +} + +export interface IEndpointVariables { + [name: string]: string | undefined; +} + +export interface IRowObject { + [name: string]: TColumnValue; +} + +export interface IRow extends IRowObject { + _id: string; + _ctime: string; + _mtime: string; + _seq?: number; +} + +export interface IName { + name: string; +} + + +type TOperation = 'cloudHosted' | 'selfHosted'; + +export interface ICredential { + token: string; + domain: string; + environment: TOperation; +} + +interface IBase { + dtable_uuid: string; + access_token: string; +} + +export interface ICtx { + base?: IBase; + credentials?: ICredential; +} + +export interface IRowResponse{ + metadata: [ + { + key: string, + name: string + } + ]; + results: IRow[]; +} diff --git a/packages/nodes-base/nodes/SeaTable/RowDescription.ts b/packages/nodes-base/nodes/SeaTable/RowDescription.ts new file mode 100644 index 0000000000..0601585318 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/RowDescription.ts @@ -0,0 +1,348 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const rowOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a row', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a row', + }, + { + name: 'Get', + value: 'get', + description: 'Get a row', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all rows', + }, + { + name: 'Update', + value: 'update', + description: 'Update a row', + }, + ], + default: 'create', + description: 'The operation being performed', + }, +] as INodeProperties[]; + +export const rowFields = [ + // ---------------------------------- + // shared + // ---------------------------------- + + { + displayName: 'Table Name/ID', + name: 'tableName', + type: 'options', + placeholder: 'Name of table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + hide: { + operation: [ + 'get', + ], + }, + }, + default: '', + description: 'The name of SeaTable table to access', + }, + { + displayName: 'Table Name/ID', + name: 'tableId', + type: 'options', + placeholder: 'Name of table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableIds', + }, + displayOptions: { + show: { + operation: [ + 'get', + ], + }, + }, + default: '', + description: 'The name of SeaTable table to access', + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'update', + ], + }, + }, + default: '', + }, + + // ---------------------------------- + // create + // ---------------------------------- + { + displayName: 'Data to Send', + name: 'fieldsToSend', + type: 'options', + options: [ + { + name: 'Auto-Map Input Data to Columns', + value: 'autoMapInputData', + description: 'Use when node input properties match destination column names', + }, + { + name: 'Define Below for Each Column', + value: 'defineBelow', + description: 'Set the value for each destination column', + }, + ], + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + }, + }, + default: 'defineBelow', + description: 'Whether to insert the input data this node receives in the new row', + }, + { + displayName: 'Inputs to Ignore', + name: 'inputsToIgnore', + type: 'string', + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + fieldsToSend: [ + 'autoMapInputData', + ], + }, + }, + default: '', + description: 'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.', + placeholder: 'Enter properties...', + }, + { + displayName: 'Columns to Send', + name: 'columnsUi', + placeholder: 'Add Column', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Column to Send', + multipleValues: true, + }, + options: [ + { + displayName: 'Column', + name: 'columnValues', + values: [ + { + displayName: 'Column Name', + name: 'columnName', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'table', + ], + loadOptionsMethod: 'getTableUpdateAbleColumns', + }, + default: '', + }, + { + displayName: 'Column Value', + name: 'columnValue', + type: 'string', + default: '', + }, + ], + }, + ], + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + fieldsToSend: [ + 'defineBelow', + ], + }, + }, + default: {}, + description: 'Add destination column with its value', + }, + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'delete', + ], + }, + }, + default: '', + }, + + // ---------------------------------- + // get + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'get', + ], + }, + }, + default: '', + }, + + // ---------------------------------- + // getAll + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: true, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'View Name', + name: 'view_name', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getViews', + }, + default: '', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Convert Link ID', + name: 'convert_link_id', + type: 'boolean', + default: false, + description: `Whether the link column in the returned row is the ID of the linked row or the name of the linked row`, + }, + { + displayName: 'Direction', + name: 'direction', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'asc', + }, + { + name: 'Descending', + value: 'desc', + }, + ], + default: 'asc', + description: `The direction of the sort, ascending (asc) or descending (desc)`, + }, + { + displayName: 'Order By', + name: 'order_by', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAllSortableColumns', + }, + default: '', + description: `A column's name or ID, use this column to sort the rows`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/SeaTable/Schema.ts b/packages/nodes-base/nodes/SeaTable/Schema.ts new file mode 100644 index 0000000000..50eec9d26e --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/Schema.ts @@ -0,0 +1,49 @@ +import {TColumnType, TDateTimeFormat, TInheritColumnKey} from './types'; + +export type ColumnType = keyof typeof schema.columnTypes; + +export const schema = { + rowFetchSegmentLimit: 1000, + dateTimeFormat: 'YYYY-MM-DDTHH:mm:ss.SSSZ', + internalNames: { + '_id': 'text', + '_creator': 'creator', + '_ctime': 'ctime', + '_last_modifier': 'last-modifier', + '_mtime': 'mtime', + '_seq': 'auto-number', + }, + columnTypes: { + text: 'Text', + 'long-text': 'Long Text', + number: 'Number', + collaborator: 'Collaborator', + date: 'Date', + duration: 'Duration', + 'single-select': 'Single Select', + 'multiple-select': 'Multiple Select', + email: 'Email', + url: 'URL', + 'rate': 'Rating', + checkbox: 'Checkbox', + formula: 'Formula', + creator: 'Creator', + ctime: 'Created time', + 'last-modifier': 'Last Modifier', + mtime: 'Last modified time', + 'auto-number': 'Auto number', + }, + nonUpdateAbleColumnTypes: { + 'creator': 'creator', + 'ctime': 'ctime', + 'last-modifier': 'last-modifier', + 'mtime': 'mtime', + 'auto-number': 'auto-number', + }, +} as { + rowFetchSegmentLimit: number, + dateTimeFormat: TDateTimeFormat, + internalNames: { [key in TInheritColumnKey]: ColumnType } + columnTypes: { [key in TColumnType]: string } + nonUpdateAbleColumnTypes: { [key in ColumnType]: ColumnType } +}; diff --git a/packages/nodes-base/nodes/SeaTable/SeaTable.node.json b/packages/nodes-base/nodes/SeaTable/SeaTable.node.json new file mode 100644 index 0000000000..75e6a5d681 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/SeaTable.node.json @@ -0,0 +1,72 @@ +{ + "node": "n8n-nodes-base.seaTable", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Data & Storage" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/seaTable" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.seaTable/" + } + ], + "generic": [ + { + "label": "2021 Goals: Level Up Your Vocabulary With Vonage and n8n", + "icon": "🎯", + "url": "https://n8n.io/blog/2021-goals-level-up-your-vocabulary-with-vonage-and-n8n/" + }, + { + "label": "2021: The Year to Automate the New You with n8n", + "icon": "☀️", + "url": "https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/" + }, + { + "label": "15 Google apps you can combine and automate to increase productivity", + "icon": "💡", + "url": "https://n8n.io/blog/automate-google-apps-for-productivity/" + }, + { + "label": "Building an expense tracking app in 10 minutes", + "icon": "📱", + "url": "https://n8n.io/blog/building-an-expense-tracking-app-in-10-minutes/" + }, + { + "label": "Why this Product Manager loves workflow automation with n8n", + "icon": "🧠", + "url": "https://n8n.io/blog/why-this-product-manager-loves-workflow-automation-with-n8n/" + }, + { + "label": "Learn to Build Powerful API Endpoints Using Webhooks", + "icon": "🧰", + "url": "https://n8n.io/blog/learn-to-build-powerful-api-endpoints-using-webhooks/" + }, + { + "label": "Sending SMS the Low-Code Way with SeaTable, Twilio Programmable SMS, and n8n", + "icon": "📱", + "url": "https://n8n.io/blog/sending-sms-the-low-code-way-with-seatable-twilio-programmable-sms-and-n8n/" + }, + { + "label": "Automating Conference Organization Processes with n8n", + "icon": "🙋‍♀️", + "url": "https://n8n.io/blog/automating-conference-organization-processes-with-n8n/" + }, + { + "label": "Benefits of automation and n8n: An interview with HubSpot's Hugh Durkin", + "icon": "🎖", + "url": "https://n8n.io/blog/benefits-of-automation-and-n8n-an-interview-with-hubspots-hugh-durkin/" + }, + { + "label": "How Goomer automated their operations with over 200 n8n workflows", + "icon": "🛵", + "url": "https://n8n.io/blog/how-goomer-automated-their-operations-with-over-200-n8n-workflows/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts b/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts new file mode 100644 index 0000000000..baea6596e0 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts @@ -0,0 +1,319 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + NodeOperationError, +} from 'n8n-workflow'; + +import { + getTableColumns, + getTableViews, + rowExport, + rowFormatColumns, + rowMapKeyToName, + seaTableApiRequest, + setableApiRequestAllItems, + split, + updateAble, +} from './GenericFunctions'; + +import { + rowFields, + rowOperations, +} from './RowDescription'; + +import { + TColumnsUiValues, + TColumnValue, +} from './types'; + +import { + ICtx, + IRow, + IRowObject, +} from './Interfaces'; + +export class SeaTable implements INodeType { + description: INodeTypeDescription = { + displayName: 'SeaTable', + name: 'seaTable', + icon: 'file:seaTable.svg', + group: ['input'], + version: 1, + subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', + description: 'Consume the SeaTable API', + defaults: { + name: 'SeaTable', + color: '#FF8000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Row', + value: 'row', + }, + ], + default: 'row', + description: 'The resource to operate on', + }, + ...rowOperations, + ...rowFields, + ], + }; + + methods = { + loadOptions: { + async getTableNames(this: ILoadOptionsFunctions) { + const returnData: INodePropertyOptions[] = []; + const { metadata: { tables } } = await seaTableApiRequest.call(this, {}, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata`); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table.name, + }); + } + return returnData; + }, + async getTableIds(this: ILoadOptionsFunctions) { + const returnData: INodePropertyOptions[] = []; + const { metadata: { tables } } = await seaTableApiRequest.call(this, {}, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata`); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table._id, + }); + } + return returnData; + }, + + async getTableUpdateAbleColumns(this: ILoadOptionsFunctions) { + const tableName = this.getNodeParameter('tableName') as string; + const columns = await getTableColumns.call(this, tableName,); + return columns.filter(column => column.editable).map(column => ({ name: column.name, value: column.name })); + }, + async getAllSortableColumns(this: ILoadOptionsFunctions) { + const tableName = this.getNodeParameter('tableName') as string; + const columns = await getTableColumns.call(this, tableName); + return columns.filter(column => !['file', 'image', 'url', 'collaborator', 'long-text'].includes(column.type)).map(column => ({ name: column.name, value: column.name })); + }, + async getViews(this: ILoadOptionsFunctions) { + const tableName = this.getNodeParameter('tableName') as string; + const views = await getTableViews.call(this, tableName); + return views.map(view => ({ name: view.name, value: view.name })); + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + let responseData; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + const body: IDataObject = {}; + const qs: IDataObject = {}; + const ctx: ICtx = {}; + + if (resource === 'row') { + if (operation === 'create') { + // ---------------------------------- + // row:create + // ---------------------------------- + + const tableName = this.getNodeParameter('tableName', 0) as string; + const tableColumns = await getTableColumns.call(this, tableName); + + body.table_name = tableName; + + const fieldsToSend = this.getNodeParameter('fieldsToSend', 0) as 'defineBelow' | 'autoMapInputData'; + let rowInput: IRowObject = {}; + + for (let i = 0; i < items.length; i++) { + rowInput = {} as IRowObject; + try { + if (fieldsToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const inputDataToIgnore = split(this.getNodeParameter('inputsToIgnore', i, '') as string); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + rowInput[key] = items[i].json[key] as TColumnValue; + } + } else { + const columns = this.getNodeParameter('columnsUi.columnValues', i, []) as TColumnsUiValues; + for (const column of columns) { + rowInput[column.columnName] = column.columnValue; + } + } + body.row = rowExport(rowInput, updateAble(tableColumns)); + + responseData = await seaTableApiRequest.call(this, ctx, 'POST', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/`, body); + + const { _id: insertId } = responseData; + if (insertId === undefined) { + throw new NodeOperationError(this.getNode(), 'SeaTable: No identity after appending row.'); + } + + const newRowInsertData = rowMapKeyToName(responseData, tableColumns); + + qs.table_name = tableName; + qs.convert = true; + const newRow = await seaTableApiRequest.call(this, ctx, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/${encodeURIComponent(insertId)}/`, body, qs); + + if (newRow._id === undefined) { + throw new NodeOperationError(this.getNode(), 'SeaTable: No identity for appended row.'); + } + + const row = rowFormatColumns({ ...newRowInsertData, ...newRow }, tableColumns.map(({ name }) => name).concat(['_id', '_ctime', '_mtime'])); + + returnData.push(row); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + } else if (operation === 'get') { + for (let i = 0; i < items.length; i++) { + try { + const tableId = this.getNodeParameter('tableId', 0) as string; + const rowId = this.getNodeParameter('rowId', i) as string; + const response = await seaTableApiRequest.call(this, ctx, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/${rowId}`, {}, { table_id: tableId, convert: true }) as IDataObject; + returnData.push(response); + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + } else if (operation === 'getAll') { + // ---------------------------------- + // row:getAll + // ---------------------------------- + + const tableName = this.getNodeParameter('tableName', 0) as string; + const tableColumns = await getTableColumns.call(this, tableName); + + try { + for (let i = 0; i < items.length; i++) { + const endpoint = `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/`; + qs.table_name = tableName; + const filters = this.getNodeParameter('filters', i) as IDataObject; + const options = this.getNodeParameter('options', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + + Object.assign(qs, filters, options); + + if (returnAll) { + responseData = await setableApiRequestAllItems.call(this, ctx, 'rows', 'GET', endpoint, body, qs); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await seaTableApiRequest.call(this, ctx, 'GET', endpoint, body, qs); + responseData = responseData.rows; + } + + const rows = responseData.map((row: IRow) => rowFormatColumns({ ...row }, tableColumns.map(({ name }) => name).concat(['_id', '_ctime', '_mtime']))); + + returnData.push(...rows); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + } + throw error; + } + } else if (operation === 'delete') { + for (let i = 0; i < items.length; i++) { + try { + const tableName = this.getNodeParameter('tableName', 0) as string; + const rowId = this.getNodeParameter('rowId', i) as string; + const body: IDataObject = { + table_name: tableName, + row_id: rowId, + }; + const response = await seaTableApiRequest.call(this, ctx, 'DELETE', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/`, body, qs) as IDataObject; + returnData.push(response); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + } else if (operation === 'update') { + // ---------------------------------- + // row:update + // ---------------------------------- + + const tableName = this.getNodeParameter('tableName', 0) as string; + const tableColumns = await getTableColumns.call(this, tableName); + + body.table_name = tableName; + + const fieldsToSend = this.getNodeParameter('fieldsToSend', 0) as 'defineBelow' | 'autoMapInputData'; + let rowInput: IRowObject = {}; + + for (let i = 0; i < items.length; i++) { + const rowId = this.getNodeParameter('rowId', i) as string; + rowInput = {} as IRowObject; + try { + if (fieldsToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const inputDataToIgnore = split(this.getNodeParameter('inputsToIgnore', i, '') as string); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + rowInput[key] = items[i].json[key] as TColumnValue; + } + } else { + const columns = this.getNodeParameter('columnsUi.columnValues', i, []) as TColumnsUiValues; + for (const column of columns) { + rowInput[column.columnName] = column.columnValue; + } + } + body.row = rowExport(rowInput, updateAble(tableColumns)); + body.table_name = tableName; + body.row_id = rowId; + responseData = await seaTableApiRequest.call(this, ctx, 'PUT', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/`, body); + + returnData.push(responseData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + } else { + throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not known!`); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json new file mode 100644 index 0000000000..9799673776 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.seaTableTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Data & Storage" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/seaTable" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.seaTableTrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts new file mode 100644 index 0000000000..7390162114 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts @@ -0,0 +1,158 @@ +import { + IPollFunctions, +} from 'n8n-core'; + +import { + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + getColumns, + rowFormatColumns, + seaTableApiRequest, + simplify, +} from './GenericFunctions'; + +import { + ICtx, + IRow, + IRowResponse, +} from './Interfaces'; + +import * as moment from 'moment'; + +export class SeaTableTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'SeaTable Trigger', + name: 'seaTableTrigger', + icon: 'file:seaTable.svg', + group: ['trigger'], + version: 1, + description: 'Starts the workflow when SeaTable events occur', + subtitle: '={{$parameter["event"]}}', + defaults: { + name: 'SeaTable Trigger', + color: '#FF8000', + }, + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + polling: true, + inputs: [], + outputs: ['main'], + properties: [ + { + displayName: 'Table', + name: 'tableName', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + default: '', + description: 'The name of SeaTable table to access', + }, + { + displayName: 'Event', + name: 'event', + type: 'options', + options: [ + { + name: 'Row Created', + value: 'rowCreated', + description: 'Trigger on newly created rows', + }, + // { + // name: 'Row Modified', + // value: 'rowModified', + // description: 'Trigger has recently modified rows', + // }, + ], + default: 'rowCreated', + }, + { + displayName: 'Simplify Response', + name: 'simple', + type: 'boolean', + default: true, + description: 'Return a simplified version of the response instead of the raw data', + }, + ], + }; + + methods = { + loadOptions: { + async getTableNames(this: ILoadOptionsFunctions) { + const returnData: INodePropertyOptions[] = []; + const { metadata: { tables } } = await seaTableApiRequest.call(this, {}, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata`); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table.name, + }); + } + return returnData; + }, + }, + }; + + async poll(this: IPollFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const tableName = this.getNodeParameter('tableName') as string; + const simple = this.getNodeParameter('simple') as boolean; + const event = this.getNodeParameter('event') as string; + const ctx: ICtx = {}; + + const now = moment().utc().format(); + + const startDate = webhookData.lastTimeChecked as string || now; + + const endDate = now; + + webhookData.lastTimeChecked = endDate; + + let rows; + + const filterField = (event === 'rowCreated') ? '_ctime' : '_mtime'; + + const endpoint = `/dtable-db/api/v1/query/{{dtable_uuid}}/`; + + if (this.getMode() === 'manual') { + rows = await seaTableApiRequest.call(this, ctx, 'POST', endpoint, { sql: `SELECT * FROM ${tableName} LIMIT 1` }) as IRowResponse; + } else { + rows = await seaTableApiRequest.call(this, ctx, 'POST', endpoint, + { sql: `SELECT * FROM ${tableName} WHERE ${filterField} BETWEEN "${moment(startDate).utc().format('YYYY-MM-D HH:mm:ss')}" AND "${moment(endDate).utc().format('YYYY-MM-D HH:mm:ss')}"` }) as IRowResponse; + } + + let response; + + if (rows.metadata && rows.results) { + const columns = getColumns(rows); + if (simple === true) { + response = simplify(rows, columns); + } else { + response = rows.results; + } + + const allColumns = rows.metadata.map((meta) => meta.name); + + response = response + //@ts-ignore + .map((row: IRow) => rowFormatColumns(row, allColumns)) + .map((row: IRow) => ({ json: row })); + } + + if (Array.isArray(response) && response.length) { + return [response]; + } + + return null; + } +} diff --git a/packages/nodes-base/nodes/SeaTable/seaTable.svg b/packages/nodes-base/nodes/SeaTable/seaTable.svg new file mode 100644 index 0000000000..472598576d --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/seaTable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/SeaTable/types.d.ts b/packages/nodes-base/nodes/SeaTable/types.d.ts new file mode 100644 index 0000000000..f76460ce26 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/types.d.ts @@ -0,0 +1,69 @@ +// ---------------------------------- +// sea-table +// ---------------------------------- + +type TSeaTableServerVersion = '2.0.6'; +type TSeaTableServerEdition = 'enterprise edition'; + +// ---------------------------------- +// dtable +// ---------------------------------- + +import {IDtableMetadataColumn, IDtableMetadataTable, TDtableViewColumn} from './Interfaces'; +import {ICredentialDataDecryptedObject} from 'n8n-workflow'; + +type TInheritColumnTypeTime = 'ctime' | 'mtime'; +type TInheritColumnTypeUser = 'creator' | 'last-modifier'; +type TColumnType = 'text' | 'long-text' | 'number' + | 'collaborator' + | 'date' | 'duration' | 'single-select' | 'multiple-select' | 'email' | 'url' | 'rate' + | 'checkbox' | 'formula' + | TInheritColumnTypeTime | TInheritColumnTypeUser | 'auto-number'; + + +type TImplementInheritColumnKey = '_seq'; +type TInheritColumnKey = '_id' | '_creator' | '_ctime' | '_last_modifier' | '_mtime' | TImplementInheritColumnKey; + +type TColumnValue = undefined | boolean | number | string | string[] | null; +type TColumnKey = TInheritColumnKey | string; + +export type TDtableMetadataTables = ReadonlyArray; +export type TDtableMetadataColumns = ReadonlyArray; +export type TDtableViewColumns = ReadonlyArray; + +// ---------------------------------- +// api +// ---------------------------------- + +type TEndpointVariableName = 'access_token' | 'dtable_uuid' | 'server'; + +// Template Literal Types requires-ts-4.1.5 -- deferred +type TMethod = 'GET' | 'POST'; +type TDeferredEndpoint = string; +type TDeferredEndpointExpr = string; +type TEndpoint = + '/api/v2.1/dtable/app-access-token/' + | '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/' + | TDeferredEndpoint; +type TEndpointExpr = TEndpoint | TDeferredEndpointExpr; +type TEndpointResolvedExpr = TEndpoint | string; /* deferred: but already in use for header values, e.g. authentication */ + +type TDateTimeFormat = 'YYYY-MM-DDTHH:mm:ss.SSSZ' /* moment.js */; + +// ---------------------------------- +// node +// ---------------------------------- + +type TCredentials = ICredentialDataDecryptedObject | undefined; + +type TTriggerOperation = 'create' | 'update'; + +type TOperation = 'append' | 'list' | 'metadata'; + +type TLoadedResource = { + name: string; +}; +export type TColumnsUiValues = Array<{ + columnName: string; + columnValue: string; +}>; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 0e41de7a59..e8d5d27196 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -230,6 +230,7 @@ "dist/credentials/SalesforceJwtApi.credentials.js", "dist/credentials/SalesforceOAuth2Api.credentials.js", "dist/credentials/SalesmateApi.credentials.js", + "dist/credentials/SeaTableApi.credentials.js", "dist/credentials/SecurityScorecardApi.credentials.js", "dist/credentials/SegmentApi.credentials.js", "dist/credentials/SendGridApi.credentials.js", @@ -548,6 +549,8 @@ "dist/nodes/Rundeck/Rundeck.node.js", "dist/nodes/S3/S3.node.js", "dist/nodes/Salesforce/Salesforce.node.js", + "dist/nodes/SeaTable/SeaTable.node.js", + "dist/nodes/SeaTable/SeaTableTrigger.node.js", "dist/nodes/SecurityScorecard/SecurityScorecard.node.js", "dist/nodes/Set.node.js", "dist/nodes/SentryIo/SentryIo.node.js",