diff --git a/packages/nodes-base/credentials/SeaTableApi.credentials.ts b/packages/nodes-base/credentials/SeaTableApi.credentials.ts index df17f6ae25..6490bf5bc6 100644 --- a/packages/nodes-base/credentials/SeaTableApi.credentials.ts +++ b/packages/nodes-base/credentials/SeaTableApi.credentials.ts @@ -1,5 +1,10 @@ import moment from 'moment-timezone'; -import type { ICredentialType, INodeProperties, INodePropertyOptions } from 'n8n-workflow'; +import type { + ICredentialTestRequest, + ICredentialType, + INodeProperties, + INodePropertyOptions, +} from 'n8n-workflow'; // Get options for timezones const timezones: INodePropertyOptions[] = moment.tz @@ -40,7 +45,7 @@ export class SeaTableApi implements ICredentialType { name: 'domain', type: 'string', default: '', - placeholder: 'https://www.mydomain.com', + placeholder: 'https://seatable.example.com', displayOptions: { show: { environment: ['selfHosted'], @@ -51,6 +56,8 @@ export class SeaTableApi implements ICredentialType { displayName: 'API Token (of a Base)', name: 'token', type: 'string', + description: + 'The API-Token of the SeaTable base you would like to use with n8n. n8n can only connect to one base at a time.', typeOptions: { password: true }, default: '', }, @@ -63,4 +70,14 @@ export class SeaTableApi implements ICredentialType { options: [...timezones], }, ]; + + test: ICredentialTestRequest = { + request: { + baseURL: '={{$credentials?.domain || "https://cloud.seatable.io" }}', + url: '/api/v2.1/dtable/app-access-token/', + headers: { + Authorization: '={{"Token " + $credentials.token}}', + }, + }, + }; } diff --git a/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts b/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts index a9f4a48726..df10ed2e9d 100644 --- a/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts +++ b/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts @@ -1,449 +1,27 @@ -import type { - IExecuteFunctions, - IDataObject, - ILoadOptionsFunctions, - INodeExecutionData, - INodePropertyOptions, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -import { - getTableColumns, - getTableViews, - rowExport, - rowFormatColumns, - rowMapKeyToName, - seaTableApiRequest, - setableApiRequestAllItems, - split, - updateAble, -} from './GenericFunctions'; -import type { ICtx, IRow, IRowObject } from './Interfaces'; -import { rowFields, rowOperations } from './RowDescription'; -import type { TColumnsUiValues, TColumnValue } from './types'; +import { SeaTableV1 } from './v1/SeaTableV1.node'; +import { SeaTableV2 } from './v2/SeaTableV2.node'; -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', - }, - usableAsTool: true, - inputs: [NodeConnectionType.Main], - outputs: [NodeConnectionType.Main], - credentials: [ - { - name: 'seaTableApi', - required: true, - }, - ], - properties: [ - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Row', - value: 'row', - }, - ], - default: 'row', - }, - ...rowOperations, - ...rowFields, - ], - }; +export class SeaTable extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'SeaTable', + name: 'seaTable', + icon: 'file:seaTable.svg', + group: ['output'], + subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', + description: 'Read, update, write and delete data from SeaTable', + defaultVersion: 2, + usableAsTool: true, + }; - 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; - }, + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new SeaTableV1(baseDescription), + 2: new SeaTableV2(baseDescription), + }; - 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: INodeExecutionData[] = []; - let responseData; - - const resource = this.getNodeParameter('resource', 0); - const operation = this.getNodeParameter('operation', 0); - - 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.', - { itemIndex: i }, - ); - } - - const newRowInsertData = rowMapKeyToName(responseData as IRow, 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 as string, - )}/`, - body, - qs, - ); - - if (newRow._id === undefined) { - throw new NodeOperationError( - this.getNode(), - 'SeaTable: No identity for appended row.', - { itemIndex: i }, - ); - } - - const row = rowFormatColumns( - { ...newRowInsertData, ...(newRow as IRow) }, - tableColumns.map(({ name }) => name).concat(['_id', '_ctime', '_mtime']), - ); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(row), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } catch (error) { - if (this.continueOnFail()) { - const executionErrorData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ error: error.message }), - { itemData: { item: i } }, - ); - returnData.push(...executionErrorData); - 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; - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } catch (error) { - if (this.continueOnFail()) { - const executionErrorData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ error: error.message }), - { itemData: { item: i } }, - ); - returnData.push(...executionErrorData); - continue; - } - throw error; - } - } - } else if (operation === 'getAll') { - // ---------------------------------- - // row:getAll - // ---------------------------------- - - const tableName = this.getNodeParameter('tableName', 0) as string; - const tableColumns = await getTableColumns.call(this, tableName); - - for (let i = 0; i < items.length; i++) { - try { - const endpoint = '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/'; - qs.table_name = tableName; - const filters = this.getNodeParameter('filters', i); - const options = this.getNodeParameter('options', i); - const returnAll = this.getNodeParameter('returnAll', 0); - - Object.assign(qs, filters, options); - - if (qs.convert_link_id === false) { - delete qs.convert_link_id; - } - - if (returnAll) { - responseData = await setableApiRequestAllItems.call( - this, - ctx, - 'rows', - 'GET', - endpoint, - body, - qs, - ); - } else { - qs.limit = this.getNodeParameter('limit', 0); - 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']), - ), - ); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(rows as IDataObject[]), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } catch (error) { - if (this.continueOnFail()) { - const executionErrorData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ error: error.message }), - { itemData: { item: i } }, - ); - returnData.push(...executionErrorData); - } - 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 requestBody: IDataObject = { - table_name: tableName, - row_id: rowId, - }; - const response = (await seaTableApiRequest.call( - this, - ctx, - 'DELETE', - '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/', - requestBody, - qs, - )) as IDataObject; - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } catch (error) { - if (this.continueOnFail()) { - const executionErrorData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ error: error.message }), - { itemData: { item: i } }, - ); - returnData.push(...executionErrorData); - 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, - ); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ _id: rowId, ...(responseData as IDataObject) }), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } catch (error) { - if (this.continueOnFail()) { - const executionErrorData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ error: error.message }), - { itemData: { item: i } }, - ); - returnData.push(...executionErrorData); - continue; - } - throw error; - } - } - } else { - throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not known!`); - } - } - return [returnData]; + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts index 709367a6f5..d80076e616 100644 --- a/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts +++ b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts @@ -1,157 +1,25 @@ -import moment from 'moment-timezone'; -import { - type IPollFunctions, - type ILoadOptionsFunctions, - type INodeExecutionData, - type INodePropertyOptions, - type INodeType, - type INodeTypeDescription, - NodeConnectionType, -} from 'n8n-workflow'; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -import { getColumns, rowFormatColumns, seaTableApiRequest, simplify } from './GenericFunctions'; -import type { ICtx, IRow, IRowResponse } from './Interfaces'; +import { SeaTableTriggerV1 } from './v1/SeaTableTriggerV1.node'; +import { SeaTableTriggerV2 } from './v2/SeaTableTriggerV2.node'; -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', - }, - credentials: [ - { - name: 'seaTableApi', - required: true, - }, - ], - polling: true, - inputs: [], - outputs: [NodeConnectionType.Main], - properties: [ - { - displayName: 'Table Name or ID', - name: 'tableName', - type: 'options', - required: true, - typeOptions: { - loadOptionsMethod: 'getTableNames', - }, - default: '', - description: - 'The name of SeaTable table to access. Choose from the list, or specify an ID using an expression.', - }, - { - 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', - name: 'simple', - type: 'boolean', - default: true, - description: - 'Whether to return a simplified version of the response instead of the raw data', - }, - ], - }; +export class SeaTableTrigger extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'SeaTable Trigger', + name: 'seaTableTrigger', + icon: 'file:seaTable.svg', + group: ['trigger'], + defaultVersion: 2, + description: 'Starts the workflow when SeaTable events occur', + }; - 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; - }, - }, - }; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new SeaTableTriggerV1(baseDescription), + 2: new SeaTableTriggerV2(baseDescription), + }; - 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 credentials = await this.getCredentials('seaTableApi'); - - const timezone = (credentials.timezone as string) || 'Europe/Berlin'; - 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).tz(timezone).format('YYYY-MM-D HH:mm:ss')}" - AND "${moment(endDate).tz(timezone).format('YYYY-MM-D HH:mm:ss')}"`, - })) as IRowResponse; - } - - let response; - - if (rows.metadata && rows.results) { - const columns = getColumns(rows); - if (simple) { - 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; + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/SeaTable/__tests__/v2/GenericFunctions.test.ts b/packages/nodes-base/nodes/SeaTable/__tests__/v2/GenericFunctions.test.ts new file mode 100644 index 0000000000..7738eb0e78 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/__tests__/v2/GenericFunctions.test.ts @@ -0,0 +1,317 @@ +import type { + ICollaborator, + IDtableMetadataColumn, + IRow, + IRowObject, + IColumnDigitalSignature, +} from '../../v2/actions/Interfaces'; +import { + enrichColumns, + rowExport, + simplify_new, + splitStringColumnsToArrays, +} from '../../v2/GenericFunctions'; +import type { TDtableMetadataColumns } from '../../v2/types'; + +describe('Seatable > v2 > GenericFunctions', () => { + describe('rowExport', () => { + const mockColumns: TDtableMetadataColumns = [ + { key: 'a', name: 'id', type: 'text' }, + { key: 'b', name: 'name', type: 'text' }, + { key: 'c', name: 'age', type: 'number' }, + ]; + + it('should export only allowed columns from row', () => { + const row: IRowObject = { + id: '1', + name: 'John', + age: 30, + extraField: 'should not be included', + }; + + const expected: IRowObject = { + id: '1', + name: 'John', + age: 30, + }; + + expect(rowExport(row, mockColumns)).toEqual(expected); + }); + + it('should handle empty row', () => { + const row: IRowObject = {}; + expect(rowExport(row, mockColumns)).toEqual({}); + }); + + it('should handle row with missing fields', () => { + const row: IRowObject = { + id: '1', + // name is missing + age: 30, + }; + + const expected: IRowObject = { + id: '1', + age: 30, + }; + + expect(rowExport(row, mockColumns)).toEqual(expected); + }); + }); + + describe('splitStringColumnsToArrays', () => { + it('should convert collaborator strings to arrays', () => { + const columns: TDtableMetadataColumns = [ + { key: 'a', name: 'collaborators', type: 'collaborator' }, + ]; + const row: IRowObject = { + collaborators: 'john@example.com, jane@example.com', + }; + + const result = splitStringColumnsToArrays(row, columns); + expect(result.collaborators).toEqual(['john@example.com', 'jane@example.com']); + }); + + it('should convert multiple-select strings to arrays', () => { + const columns: TDtableMetadataColumns = [{ key: 'a', name: 'tags', type: 'multiple-select' }]; + const row: IRowObject = { + tags: 'urgent, important', + }; + + const result = splitStringColumnsToArrays(row, columns); + expect(result.tags).toEqual(['urgent', 'important']); + }); + + it('should convert number strings to numbers', () => { + const columns: TDtableMetadataColumns = [{ key: 'a', name: 'amount', type: 'number' }]; + const row: IRowObject = { + amount: '123.45', + }; + + const result = splitStringColumnsToArrays(row, columns); + expect(result.amount).toBe(123.45); + }); + + it('should convert rate and duration strings to integers', () => { + const columns: TDtableMetadataColumns = [ + { key: 'a', name: 'rating', type: 'rate' }, + { key: 'b', name: 'duration', type: 'duration' }, + ]; + const row: IRowObject = { + rating: '4', + duration: '60', + }; + + const result = splitStringColumnsToArrays(row, columns); + expect(result.rating).toBe(4); + expect(result.duration).toBe(60); + }); + + it('should convert checkbox strings to booleans', () => { + const columns: TDtableMetadataColumns = [{ key: 'a', name: 'isActive', type: 'checkbox' }]; + const testCases = [ + { input: 'true', expected: true }, + { input: 'on', expected: true }, + { input: '1', expected: true }, + { input: 'false', expected: false }, + { input: 'off', expected: false }, + { input: '0', expected: false }, + ]; + + testCases.forEach(({ input, expected }) => { + const row: IRowObject = { isActive: input }; + const result = splitStringColumnsToArrays(row, columns); + expect(result.isActive).toBe(expected); + }); + }); + + it('should handle multiple column types in one row', () => { + const columns: TDtableMetadataColumns = [ + { key: 'a', name: 'tags', type: 'multiple-select' }, + { key: 'b', name: 'amount', type: 'number' }, + { key: 'c', name: 'isActive', type: 'checkbox' }, + ]; + const row: IRowObject = { + tags: 'tag1, tag2', + amount: '123.45', + isActive: 'true', + }; + + const result = splitStringColumnsToArrays(row, columns); + expect(result).toEqual({ + tags: ['tag1', 'tag2'], + amount: 123.45, + isActive: true, + }); + }); + + it('should handle empty/invalid inputs', () => { + const columns: TDtableMetadataColumns = [ + { key: 'a', name: 'empty', type: 'multiple-select' }, + { key: 'b', name: 'invalid', type: 'number' }, + ]; + const row: IRowObject = { + empty: '', + invalid: 'not-a-number', + }; + + const result = splitStringColumnsToArrays(row, columns); + expect(result.empty).toEqual(['']); + expect(result.invalid).toBeNaN(); + }); + }); + + describe('enrichColumns', () => { + const baseRow = { + _id: '1234', + _ctime: '2024-01-01T00:00:00Z', + _mtime: '2024-01-01T00:00:00Z', + }; + + const mockCollaborators: ICollaborator[] = [ + { + name: 'John Doe', + email: 'john@example.com', + contact_email: 'john@example.com', + }, + { + name: 'Jane Smith', + email: 'jane@example.com', + contact_email: 'jane@example.com', + }, + ]; + + const mockMetadata: IDtableMetadataColumn[] = [ + { name: 'assignee', type: 'collaborator', key: 'assignee' }, + { name: 'creator', type: 'creator', key: '_creator' }, + { name: 'lastModifier', type: 'last-modifier', key: '_last_modifier' }, + { name: 'images', type: 'image', key: 'images' }, + { name: 'files', type: 'file', key: 'files' }, + { name: 'signature', type: 'digital-sign', key: 'signature' }, + { name: 'action', type: 'button', key: 'action' }, + ]; + + it('should preserve base IRow properties', () => { + const row: IRow = { + ...baseRow, + assignee: ['john@example.com'], + }; + + const result = enrichColumns(row, mockMetadata, mockCollaborators); + expect(result._id).toBe(baseRow._id); + expect(result._ctime).toBe(baseRow._ctime); + expect(result._mtime).toBe(baseRow._mtime); + }); + + it('should enrich collaborator columns', () => { + const row: IRow = { + ...baseRow, + assignee: ['john@example.com'], + }; + + const result = enrichColumns(row, mockMetadata, mockCollaborators); + expect(result.assignee).toEqual([ + { + email: 'john@example.com', + contact_email: 'john@example.com', + name: 'John Doe', + }, + ]); + }); + + it('should enrich creator and last-modifier columns', () => { + const row: IRow = { + ...baseRow, + creator: 'john@example.com', + lastModifier: 'jane@example.com', + }; + + const result = enrichColumns(row, mockMetadata, mockCollaborators); + expect(result.creator).toEqual({ + email: 'john@example.com', + contact_email: 'john@example.com', + name: 'John Doe', + }); + }); + + it('should enrich image columns', () => { + const row: IRow = { + ...baseRow, + images: ['https://example.com/image.jpg'], + }; + + const result = enrichColumns(row, mockMetadata, mockCollaborators); + expect(result.images).toEqual([ + { + name: 'image.jpg', + size: 0, + type: 'image', + url: 'https://example.com/image.jpg', + path: 'https://example.com/image.jpg', + }, + ]); + }); + + it('should handle empty/missing data gracefully', () => { + const row: IRow = { + ...baseRow, + assignee: [], + images: [], + files: [], + signature: {} as IColumnDigitalSignature, + }; + + const result = enrichColumns(row, mockMetadata, mockCollaborators); + expect(result.assignee).toEqual([]); + expect(result.images).toEqual([]); + expect(result.files).toEqual([]); + expect(result.signature).toEqual({}); + }); + }); + + describe('simplify_new', () => { + it('should remove keys starting with underscore', () => { + const input: IRow = { + _id: '123', + _ctime: '2024-01-01', + _mtime: '2024-01-01', + name: 'Test', + value: 42, + }; + + const expected = { + name: 'Test', + value: 42, + }; + + expect(simplify_new(input)).toEqual(expected); + }); + + it('should handle empty object', () => { + const input: IRow = { + _id: '123', + _ctime: '2024-01-01', + _mtime: '2024-01-01', + }; + + expect(simplify_new(input)).toEqual({}); + }); + + it('should preserve non-underscore keys', () => { + const input: IRow = { + _id: '123', + _ctime: '2024-01-01', + _mtime: '2024-01-01', + normal_key: 'value', + dash_key: 'value', + }; + + const expected = { + normal_key: 'value', + dash_key: 'value', + }; + + expect(simplify_new(input)).toEqual(expected); + }); + }); +}); diff --git a/packages/nodes-base/nodes/SeaTable/seaTable.svg b/packages/nodes-base/nodes/SeaTable/seaTable.svg index 437e33c87a..472598576d 100644 --- a/packages/nodes-base/nodes/SeaTable/seaTable.svg +++ b/packages/nodes-base/nodes/SeaTable/seaTable.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts b/packages/nodes-base/nodes/SeaTable/v1/GenericFunctions.ts similarity index 98% rename from packages/nodes-base/nodes/SeaTable/GenericFunctions.ts rename to packages/nodes-base/nodes/SeaTable/v1/GenericFunctions.ts index 0318e0b73a..c1a8f99e7e 100644 --- a/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts +++ b/packages/nodes-base/nodes/SeaTable/v1/GenericFunctions.ts @@ -3,9 +3,9 @@ import type { IExecuteFunctions, ILoadOptionsFunctions, IPollFunctions, - JsonObject, - IHttpRequestMethods, IRequestOptions, + IHttpRequestMethods, + JsonObject, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; @@ -64,8 +64,8 @@ function endpointCtxExpr(ctx: ICtx, endpoint: string): string { endpointVariables.dtable_uuid = ctx?.base?.dtable_uuid; return endpoint.replace( - /({{ *(access_token|dtable_uuid|server) *}})/g, - (match: string, _: string, name: TEndpointVariableName) => { + /{{ *(access_token|dtable_uuid|server) *}}/g, + (match: string, name: TEndpointVariableName) => { return endpointVariables[name] || match; }, ); @@ -76,7 +76,6 @@ export async function seaTableApiRequest( ctx: ICtx, method: IHttpRequestMethods, endpoint: string, - body: any = {}, qs: IDataObject = {}, url: string | undefined = undefined, diff --git a/packages/nodes-base/nodes/SeaTable/Interfaces.ts b/packages/nodes-base/nodes/SeaTable/v1/Interfaces.ts similarity index 100% rename from packages/nodes-base/nodes/SeaTable/Interfaces.ts rename to packages/nodes-base/nodes/SeaTable/v1/Interfaces.ts diff --git a/packages/nodes-base/nodes/SeaTable/RowDescription.ts b/packages/nodes-base/nodes/SeaTable/v1/RowDescription.ts similarity index 78% rename from packages/nodes-base/nodes/SeaTable/RowDescription.ts rename to packages/nodes-base/nodes/SeaTable/v1/RowDescription.ts index a0b144cc7a..29141a10cf 100644 --- a/packages/nodes-base/nodes/SeaTable/RowDescription.ts +++ b/packages/nodes-base/nodes/SeaTable/v1/RowDescription.ts @@ -49,10 +49,11 @@ export const rowFields: INodeProperties[] = [ // ---------------------------------- { - displayName: 'Table Name or ID', + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Table Name', name: 'tableName', type: 'options', - placeholder: 'Name of table', + placeholder: 'Name of the table', required: true, typeOptions: { loadOptionsMethod: 'getTableNames', @@ -63,14 +64,16 @@ export const rowFields: INodeProperties[] = [ }, }, default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options description: - 'The name of SeaTable table to access. Choose from the list, or specify an ID using an expression.', + 'The name of SeaTable table to access. Choose from the list, or specify the name using an expression.', }, { - displayName: 'Table Name or ID', + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Table ID', name: 'tableId', type: 'options', - placeholder: 'Name of table', + placeholder: 'ID of the table', required: true, typeOptions: { loadOptionsMethod: 'getTableIds', @@ -81,6 +84,7 @@ export const rowFields: INodeProperties[] = [ }, }, default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options description: 'The name of SeaTable table to access. Choose from the list, or specify an ID using an expression.', }, @@ -157,11 +161,13 @@ export const rowFields: INodeProperties[] = [ name: 'columnValues', values: [ { - displayName: 'Column Name or ID', + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Column Name', name: 'columnName', type: 'options', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options description: - 'Choose from the list, or specify an ID using an expression', + 'Choose from the list, or specify the name using an expression', typeOptions: { loadOptionsDependsOn: ['table'], loadOptionsMethod: 'getTableUpdateAbleColumns', @@ -243,7 +249,6 @@ export const rowFields: INodeProperties[] = [ }, typeOptions: { minValue: 1, - maxValue: 100, }, default: 50, description: 'Max number of results to return', @@ -261,11 +266,13 @@ export const rowFields: INodeProperties[] = [ }, options: [ { - displayName: 'View Name or ID', + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'View Name', name: 'view_name', type: 'options', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options description: - 'Choose from the list, or specify an ID using an expression', + 'Choose from the list, or specify an View Name using an expression', typeOptions: { loadOptionsMethod: 'getViews', }, @@ -291,7 +298,7 @@ export const rowFields: INodeProperties[] = [ 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', + 'Whether the ID of the linked row is returned in the link column (true). Otherwise, it return the name of the linked row (false).', }, { displayName: 'Direction', @@ -312,15 +319,16 @@ export const rowFields: INodeProperties[] = [ }, { // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options - displayName: 'Order By', + displayName: 'Order By Column', name: 'order_by', type: 'options', typeOptions: { loadOptionsMethod: 'getAllSortableColumns', }, default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options description: - 'A column\'s name or ID, use this column to sort the rows. Choose from the list, or specify an ID using an expression.', + 'Choose from the list, or specify a Column using an expression', }, ], }, diff --git a/packages/nodes-base/nodes/SeaTable/Schema.ts b/packages/nodes-base/nodes/SeaTable/v1/Schema.ts similarity index 100% rename from packages/nodes-base/nodes/SeaTable/Schema.ts rename to packages/nodes-base/nodes/SeaTable/v1/Schema.ts diff --git a/packages/nodes-base/nodes/SeaTable/v1/SeaTable.node.ts b/packages/nodes-base/nodes/SeaTable/v1/SeaTable.node.ts new file mode 100644 index 0000000000..7c844afc6e --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v1/SeaTable.node.ts @@ -0,0 +1,41 @@ +import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow'; + +import { rowFields, rowOperations } from './RowDescription'; + +export const versionDescription: 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', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Row', + value: 'row', + }, + ], + default: 'row', + }, + ...rowOperations, + ...rowFields, + ], +}; diff --git a/packages/nodes-base/nodes/SeaTable/v1/SeaTableTriggerV1.node.ts b/packages/nodes-base/nodes/SeaTable/v1/SeaTableTriggerV1.node.ts new file mode 100644 index 0000000000..6a2a57c06e --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v1/SeaTableTriggerV1.node.ts @@ -0,0 +1,158 @@ +import moment from 'moment-timezone'; +import { + type IPollFunctions, + type ILoadOptionsFunctions, + type INodeExecutionData, + type INodePropertyOptions, + type INodeType, + type INodeTypeDescription, + NodeConnectionType, + type INodeTypeBaseDescription, +} from 'n8n-workflow'; + +import { getColumns, rowFormatColumns, seaTableApiRequest, simplify } from './GenericFunctions'; +import type { ICtx, IRow, IRowResponse } from './Interfaces'; + +export class SeaTableTriggerV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: 1, + subtitle: '={{$parameter["event"]}}', + defaults: { + name: 'SeaTable Trigger', + }, + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + polling: true, + inputs: [], + outputs: [NodeConnectionType.Main], + properties: [ + { + displayName: 'Table Name or ID', + name: 'tableName', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify an ID using an expression.', + }, + { + 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', + name: 'simple', + type: 'boolean', + default: true, + description: + 'Whether to 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 credentials = await this.getCredentials('seaTableApi'); + + const timezone = (credentials.timezone as string) || 'Europe/Berlin'; + 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).tz(timezone).format('YYYY-MM-D HH:mm:ss')}" + AND "${moment(endDate).tz(timezone).format('YYYY-MM-D HH:mm:ss')}"`, + })) as IRowResponse; + } + + let response; + + if (rows.metadata && rows.results) { + const columns = getColumns(rows); + if (simple) { + 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/v1/SeaTableV1.node.ts b/packages/nodes-base/nodes/SeaTable/v1/SeaTableV1.node.ts new file mode 100644 index 0000000000..a5fb35313a --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v1/SeaTableV1.node.ts @@ -0,0 +1,420 @@ +import type { + IExecuteFunctions, + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + INodeTypeBaseDescription, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { + getTableColumns, + getTableViews, + rowExport, + rowFormatColumns, + rowMapKeyToName, + seaTableApiRequest, + setableApiRequestAllItems, + split, + updateAble, +} from './GenericFunctions'; +import type { ICtx, IRow, IRowObject } from './Interfaces'; +import { versionDescription } from './SeaTable.node'; +import type { TColumnsUiValues, TColumnValue } from './types'; + +export class SeaTableV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + 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: INodeExecutionData[] = []; + let responseData; + + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + 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.', + { itemIndex: i }, + ); + } + + const newRowInsertData = rowMapKeyToName(responseData as IRow, 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 as string, + )}/`, + body, + qs, + ); + + if (newRow._id === undefined) { + throw new NodeOperationError( + this.getNode(), + 'SeaTable: No identity for appended row.', + { itemIndex: i }, + ); + } + + const row = rowFormatColumns( + { ...newRowInsertData, ...(newRow as IRow) }, + tableColumns.map(({ name }) => name).concat(['_id', '_ctime', '_mtime']), + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(row), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: error.message }), + { itemData: { item: i } }, + ); + returnData.push(...executionErrorData); + 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; + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: error.message }), + { itemData: { item: i } }, + ); + returnData.push(...executionErrorData); + continue; + } + throw error; + } + } + } else if (operation === 'getAll') { + // ---------------------------------- + // row:getAll + // ---------------------------------- + + const tableName = this.getNodeParameter('tableName', 0) as string; + const tableColumns = await getTableColumns.call(this, tableName); + + for (let i = 0; i < items.length; i++) { + try { + const endpoint = '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/'; + qs.table_name = tableName; + const filters = this.getNodeParameter('filters', i); + const options = this.getNodeParameter('options', i); + const returnAll = this.getNodeParameter('returnAll', 0); + + Object.assign(qs, filters, options); + + if (qs.convert_link_id === false) { + delete qs.convert_link_id; + } + + if (returnAll) { + responseData = await setableApiRequestAllItems.call( + this, + ctx, + 'rows', + 'GET', + endpoint, + body, + qs, + ); + } else { + qs.limit = this.getNodeParameter('limit', 0); + 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']), + ), + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(rows as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: error.message }), + { itemData: { item: i } }, + ); + returnData.push(...executionErrorData); + } + 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 requestBody: IDataObject = { + table_name: tableName, + row_id: rowId, + }; + const response = (await seaTableApiRequest.call( + this, + ctx, + 'DELETE', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/', + requestBody, + qs, + )) as IDataObject; + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: error.message }), + { itemData: { item: i } }, + ); + returnData.push(...executionErrorData); + 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, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ _id: rowId, ...(responseData as IDataObject) }), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: error.message }), + { itemData: { item: i } }, + ); + returnData.push(...executionErrorData); + continue; + } + throw error; + } + } + } else { + throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not known!`); + } + } + return [returnData]; + } +} diff --git a/packages/nodes-base/nodes/SeaTable/v1/VersionDescription.ts b/packages/nodes-base/nodes/SeaTable/v1/VersionDescription.ts new file mode 100644 index 0000000000..d1b8785e18 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v1/VersionDescription.ts @@ -0,0 +1,42 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow'; + +import { rowFields, rowOperations } from './RowDescription'; + +export const versionDescription: 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', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Row', + value: 'row', + }, + ], + default: 'row', + }, + ...rowOperations, + ...rowFields, + ], +}; diff --git a/packages/nodes-base/nodes/SeaTable/types.ts b/packages/nodes-base/nodes/SeaTable/v1/types.ts similarity index 100% rename from packages/nodes-base/nodes/SeaTable/types.ts rename to packages/nodes-base/nodes/SeaTable/v1/types.ts diff --git a/packages/nodes-base/nodes/SeaTable/v2/GenericFunctions.ts b/packages/nodes-base/nodes/SeaTable/v2/GenericFunctions.ts new file mode 100644 index 0000000000..e804ffbdf5 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/GenericFunctions.ts @@ -0,0 +1,343 @@ +import type FormData from 'form-data'; +import type { + IDataObject, + IExecuteFunctions, + ILoadOptionsFunctions, + IPollFunctions, + JsonObject, + IHttpRequestMethods, + IHttpRequestOptions, + IRequestOptions, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import type { + ICollaborator, + ICollaboratorsResult, + ICredential, + ICtx, + IDtableMetadataColumn, + IEndpointVariables, + IName, + IRow, + IRowObject, + IColumnDigitalSignature, + IFile, +} from './actions/Interfaces'; +import { schema } from './Schema'; +import type { TDtableMetadataColumns, TEndpointVariableName } from './types'; + +const userBaseUri = (uri?: string) => { + if (uri === undefined) return uri; + if (uri.endsWith('/')) return uri.slice(0, -1); + return uri; +}; + +export function resolveBaseUri(ctx: ICtx) { + return ctx?.credentials?.environment === 'cloudHosted' + ? 'https://cloud.seatable.io' + : userBaseUri(ctx?.credentials?.domain); +} + +export async function getBaseAccessToken( + this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, + ctx: ICtx, +) { + if (ctx?.base?.access_token !== undefined) return; + + const options: IHttpRequestOptions = { + headers: { + Authorization: `Token ${ctx?.credentials?.token}`, + }, + url: `${resolveBaseUri(ctx)}/api/v2.1/dtable/app-access-token/`, + json: true, + }; + ctx.base = await this.helpers.httpRequest(options); +} + +function endpointCtxExpr(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, name: TEndpointVariableName) => { + return (endpointVariables[name] as string) || match; + }, + ); +} + +export async function seaTableApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, + ctx: ICtx, + method: IHttpRequestMethods, + endpoint: string, + body: IDataObject | FormData | string | Buffer = {}, + qs: IDataObject = {}, + url: string = '', + option: IDataObject = {}, +): Promise { + const credentials = await this.getCredentials('seaTableApi'); + + ctx.credentials = credentials as unknown as ICredential; + + await getBaseAccessToken.call(this, ctx); + + // some API endpoints require the api_token instead of base_access_token. + const token = + endpoint.indexOf('/api/v2.1/dtable/app-download-link/') === 0 || + endpoint == '/api/v2.1/dtable/app-upload-link/' || + endpoint.indexOf('/seafhttp/upload-api') === 0 + ? `${ctx?.credentials?.token}` + : `${ctx?.base?.access_token}`; + + let options: IRequestOptions = { + uri: url || `${resolveBaseUri(ctx)}${endpointCtxExpr(ctx, endpoint)}`, + headers: { + Authorization: `Token ${token}`, + }, + method, + qs, + body, + json: true, + }; + + if (Object.keys(option).length !== 0) { + options = Object.assign({}, options, option); + } + + // remove header from download request. + if (endpoint.indexOf('/seafhttp/files/') === 0) { + delete options.headers; + } + + // enhance header for upload request + if (endpoint.indexOf('/seafhttp/upload-api') === 0) { + options.json = true; + options.headers = { + ...options.headers, + 'Content-Type': 'multipart/form-data', + }; + } + + if (Object.keys(body).length === 0) { + delete options.body; + } + + try { + return await this.helpers.requestWithAuthentication.call(this, 'seaTableApi', options); + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +export async function getBaseCollaborators( + this: ILoadOptionsFunctions | IExecuteFunctions | IPollFunctions, +): Promise { + const collaboratorsResult: ICollaboratorsResult = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/related-users/', + ); + const collaborators: ICollaborator[] = collaboratorsResult.user_list || []; + return collaborators; +} + +export async function getTableColumns( + this: ILoadOptionsFunctions | IExecuteFunctions | IPollFunctions, + tableName: string, + ctx: ICtx = {}, +): Promise { + const { + metadata: { tables }, + } = await seaTableApiRequest.call( + this, + ctx, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata', + ); + for (const table of tables) { + if (table.name === tableName) { + return table.columns; + } + } + return []; +} + +export function simplify_new(row: IRow) { + for (const key of Object.keys(row)) { + if (key.startsWith('_')) delete row[key]; + } + return row; +} + +const namePredicate = (name: string) => (named: IName) => named.name === name; +export const nameOfPredicate = (names: readonly IName[]) => (name: string) => + names.find(namePredicate(name)); + +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, (_, $1) => $1)); + +function getCollaboratorInfo( + authLocal: string | null | undefined, + collaboratorList: ICollaborator[], +): ICollaborator { + return ( + collaboratorList.find((singleCollaborator) => singleCollaborator.email === authLocal) || { + contact_email: 'unknown', + name: 'unknown', + email: 'unknown', + } + ); +} + +function getAssetPath(type: string, url: string) { + const parts = url.split(`/${type}/`); + if (parts[1]) { + return '/' + type + '/' + parts[1]; + } + return url; +} + +export function enrichColumns( + row: IRow, + metadata: IDtableMetadataColumn[], + collaboratorList: ICollaborator[], +): IRow { + Object.keys(row).forEach((key) => { + const columnDef = metadata.find((obj) => obj.name === key || obj.key === key); + + if (columnDef?.type === 'collaborator') { + // collaborator is an array of strings. + const collaborators = (row[key] as string[]) || []; + if (collaborators.length > 0) { + const newArray = collaborators.map((email) => { + const collaboratorDetails = getCollaboratorInfo(email, collaboratorList); + const newColl = { + email, + contact_email: collaboratorDetails.contact_email, + name: collaboratorDetails.name, + }; + return newColl; + }); + row[key] = newArray; + } + } + + if ( + columnDef?.type === 'last-modifier' || + columnDef?.type === 'creator' || + columnDef?.key === '_creator' || + columnDef?.key === '_last_modifier' + ) { + // creator or last-modifier are always a single string. + const collaboratorDetails = getCollaboratorInfo(row[key] as string, collaboratorList); + row[key] = { + email: row[key], + contact_email: collaboratorDetails.contact_email, + name: collaboratorDetails.name, + }; + } + + if (columnDef?.type === 'image') { + const pictures = (row[key] as string[]) || []; + if (pictures.length > 0) { + const newArray = pictures.map((url) => ({ + name: url.split('/').pop(), + size: 0, + type: 'image', + url, + path: getAssetPath('images', url), + })); + row[key] = newArray; + } + } + + if (columnDef?.type === 'file') { + const files = (row[key] as IFile[]) || []; + files.forEach((file) => { + file.path = getAssetPath('files', file.url); + }); + } + + if (columnDef?.type === 'digital-sign') { + const digitalSignature: IColumnDigitalSignature | any = row[key]; + const collaboratorDetails = getCollaboratorInfo(digitalSignature?.username, collaboratorList); + if (digitalSignature?.username) { + digitalSignature.contact_email = collaboratorDetails.contact_email; + digitalSignature.name = collaboratorDetails.name; + } + } + + if (columnDef?.type === 'button') { + delete row[key]; + } + }); + + return row; +} + +export function splitStringColumnsToArrays( + row: IRowObject, + columns: TDtableMetadataColumns, +): IRowObject { + columns.map((column) => { + if (column.type === 'collaborator' || column.type === 'multiple-select') { + if (typeof row[column.name] === 'string') { + const input = row[column.name] as string; + row[column.name] = input.split(',').map((item) => item.trim()); + } + } + if (column.type === 'number') { + if (typeof row[column.name] === 'string') { + const input = row[column.name] as string; + row[column.name] = parseFloat(input); + } + } + if (column.type === 'rate' || column.type === 'duration') { + if (typeof row[column.name] === 'string') { + const input = row[column.name] as string; + row[column.name] = parseInt(input); + } + } + if (column.type === 'checkbox') { + if (typeof row[column.name] === 'string') { + const input = row[column.name] as string; + row[column.name] = false; + if (input === 'true' || input === 'on' || input === '1') { + row[column.name] = true; + } + } + } + }); + return row; +} + +export function rowExport(row: IRowObject, columns: TDtableMetadataColumns): IRowObject { + const rowAllowed = {} as IRowObject; + columns.map((column) => { + if (row[column.name]) { + rowAllowed[column.name] = row[column.name]; + } + }); + return rowAllowed; +} + +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); diff --git a/packages/nodes-base/nodes/SeaTable/v2/Schema.ts b/packages/nodes-base/nodes/SeaTable/v2/Schema.ts new file mode 100644 index 0000000000..d64ad3bbe6 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/Schema.ts @@ -0,0 +1,61 @@ +import type { 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', + image: 'Image', + file: 'File', + email: 'Email', + url: 'URL', + checkbox: 'Checkbox', + rate: 'Rating', + formula: 'Formula', + 'link-formula': 'Link-Formula', + geolocation: 'Geolocation', + link: 'Link', + creator: 'Creator', + ctime: 'Created time', + 'last-modifier': 'Last Modifier', + mtime: 'Last modified time', + 'auto-number': 'Auto number', + button: 'Button', + 'digital-sign': 'Digital Signature', + }, + nonUpdateAbleColumnTypes: { + creator: 'creator', + ctime: 'ctime', + 'last-modifier': 'last-modifier', + mtime: 'mtime', + 'auto-number': 'auto-number', + button: 'button', + formula: 'formula', + 'link-formula': 'link-formula', + link: 'link', + 'digital-sign': 'digital-sign', + }, +} 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/v2/SeaTableTriggerV2.node.ts b/packages/nodes-base/nodes/SeaTable/v2/SeaTableTriggerV2.node.ts new file mode 100644 index 0000000000..e6239e9cbe --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/SeaTableTriggerV2.node.ts @@ -0,0 +1,292 @@ +import moment from 'moment-timezone'; +import { + type IPollFunctions, + type INodeExecutionData, + type INodeType, + type INodeTypeDescription, + NodeConnectionType, + type INodeTypeBaseDescription, + type IDataObject, +} from 'n8n-workflow'; + +import type { + ICtx, + IRow, + IRowResponse, + IGetMetadataResult, + IGetRowsResult, + IDtableMetadataColumn, + ICollaborator, + ICollaboratorsResult, + IColumnDigitalSignature, +} from './actions/Interfaces'; +import { seaTableApiRequest, simplify_new, enrichColumns } from './GenericFunctions'; +import { loadOptions } from './methods'; + +export class SeaTableTriggerV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: 2, + subtitle: '={{$parameter["event"]}}', + defaults: { + name: 'SeaTable Trigger', + }, + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + polling: true, + inputs: [], + outputs: [NodeConnectionType.Main], + properties: [ + { + displayName: 'Event', + name: 'event', + type: 'options', + options: [ + { + name: 'New Row', + value: 'newRow', + description: 'Trigger on newly created rows', + }, + { + name: 'New or Updated Row', + value: 'updatedRow', + description: 'Trigger has recently created or modified rows', + }, + { + name: 'New Signature', + value: 'newAsset', + description: 'Trigger on new signatures', + }, + ], + default: 'newRow', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Table Name', + name: 'tableName', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'The name of SeaTable table to access. Choose from the list, or specify the name using an expression.', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'View Name', + name: 'viewName', + type: 'options', + displayOptions: { + show: { + event: ['newRow', 'updatedRow'], + }, + }, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getTableViews', + }, + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'The name of SeaTable view to access. Choose from the list, or specify the name using an expression.', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Signature Column', + name: 'assetColumn', + type: 'options', + required: true, + displayOptions: { + show: { + event: ['newAsset'], + }, + }, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getSignatureColumns', + }, + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'Select the digital-signature column that should be tracked. Choose from the list, or specify the name using an expression.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Simplify', + name: 'simple', + type: 'boolean', + default: true, + description: + 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'Return Column Names', + name: 'convert', + type: 'boolean', + default: true, + description: 'Whether to return the column keys (false) or the column names (true)', + displayOptions: { + show: { + '/event': ['newRow', 'updatedRow'], + }, + }, + }, + ], + }, + { + displayName: '"Fetch Test Event" returns max. three items of the last hour.', + name: 'notice', + type: 'notice', + default: '', + }, + ], + }; + } + + methods = { loadOptions }; + + async poll(this: IPollFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const event = this.getNodeParameter('event') as string; + const tableName = this.getNodeParameter('tableName') as string; + const viewName = (event !== 'newAsset' ? this.getNodeParameter('viewName') : '') as string; + const assetColumn = ( + event === 'newAsset' ? this.getNodeParameter('assetColumn') : '' + ) as string; + const options = this.getNodeParameter('options') as IDataObject; + + const ctx: ICtx = {}; + + const startDate = + this.getMode() === 'manual' + ? moment().utc().subtract(1, 'h').format() + : (webhookData.lastTimeChecked as string); + const endDate = (webhookData.lastTimeChecked = moment().utc().format()); + + const filterField = event === 'newRow' ? '_ctime' : '_mtime'; + + let requestMeta: IGetMetadataResult; + let requestRows: IGetRowsResult; + let metadata: IDtableMetadataColumn[] = []; + let rows: IRow[]; + let sqlResult: IRowResponse; + + const limit = this.getMode() === 'manual' ? 3 : 1000; + + // New Signature + if (event === 'newAsset') { + const endpoint = '/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql'; + sqlResult = await seaTableApiRequest.call(this, ctx, 'POST', endpoint, { + sql: `SELECT _id, _ctime, _mtime, \`${assetColumn}\` FROM ${tableName} WHERE \`${assetColumn}\` IS NOT NULL ORDER BY _mtime DESC LIMIT ${limit}`, + convert_keys: options.convert ?? true, + }); + + metadata = sqlResult.metadata as IDtableMetadataColumn[]; + const columnType = metadata.find((obj) => obj.name == assetColumn); + const assetColumnType = columnType?.type || null; + + // remove unwanted entries + rows = sqlResult.results.filter((obj) => new Date(obj._mtime) > new Date(startDate)); + + // split the objects into new lines (not necessary for digital-sign) + const newRows: any = []; + for (const row of rows) { + if (assetColumnType === 'digital-sign') { + const signature = (row[assetColumn] as IColumnDigitalSignature) || []; + if (signature.sign_time) { + if (new Date(signature.sign_time) > new Date(startDate)) { + newRows.push(signature); + } + } + } + } + } + + // View => use getRows. + else if (viewName || viewName === '') { + requestMeta = await seaTableApiRequest.call( + this, + ctx, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata/', + ); + requestRows = await seaTableApiRequest.call( + this, + ctx, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/', + {}, + { + table_name: tableName, + view_name: viewName, + limit, + convert_keys: options.convert ?? true, + }, + ); + + metadata = + requestMeta.metadata.tables.find((table) => table.name === tableName)?.columns ?? []; + + // remove unwanted rows that are too old (compare startDate with _ctime or _mtime) + if (this.getMode() === 'manual') { + rows = requestRows.rows; + } else { + rows = requestRows.rows.filter((obj) => new Date(obj[filterField]) > new Date(startDate)); + } + } else { + const endpoint = '/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql'; + const sqlQuery = `SELECT * FROM \`${tableName}\` WHERE ${filterField} BETWEEN "${moment( + startDate, + ).format('YYYY-MM-D HH:mm:ss')}" AND "${moment(endDate).format( + 'YYYY-MM-D HH:mm:ss', + )}" ORDER BY ${filterField} DESC LIMIT ${limit}`; + sqlResult = await seaTableApiRequest.call(this, ctx, 'POST', endpoint, { + sql: sqlQuery, + convert_keys: options.convert ?? true, + }); + metadata = sqlResult.metadata as IDtableMetadataColumn[]; + rows = sqlResult.results; + } + + const collaboratorsResult: ICollaboratorsResult = await seaTableApiRequest.call( + this, + ctx, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/related-users/', + ); + const collaborators: ICollaborator[] = collaboratorsResult.user_list || []; + + if (Array.isArray(rows) && rows.length > 0) { + const simple = options.simple ?? true; + // remove columns starting with _ if simple; + if (simple) { + rows = rows.map((row) => simplify_new(row)); + } + + // enrich column types like {collaborator, creator, last_modifier}, {image, file} + // remove button column from rows + rows = rows.map((row) => enrichColumns(row, metadata, collaborators)); + + // prepare for final output + return [this.helpers.returnJsonArray(rows)]; + } + + return null; + } +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/SeaTableV2.node.ts b/packages/nodes-base/nodes/SeaTable/v2/SeaTableV2.node.ts new file mode 100644 index 0000000000..3fe0dc7eec --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/SeaTableV2.node.ts @@ -0,0 +1,27 @@ +import type { + IExecuteFunctions, + INodeType, + INodeTypeDescription, + INodeTypeBaseDescription, +} from 'n8n-workflow'; + +import { router } from './actions/router'; +import { versionDescription } from './actions/SeaTable.node'; +import { loadOptions } from './methods'; + +export class SeaTableV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { loadOptions }; + + async execute(this: IExecuteFunctions) { + return await router.call(this); + } +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/Interfaces.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/Interfaces.ts new file mode 100644 index 0000000000..301dd513de --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/Interfaces.ts @@ -0,0 +1,195 @@ +import type { AllEntities, Entity, PropertiesOf } from 'n8n-workflow'; + +type SeaTableMap = { + row: 'create' | 'get' | 'search' | 'update' | 'remove' | 'lock' | 'unlock' | 'list'; + base: 'snapshot' | 'metadata' | 'collaborator'; + link: 'add' | 'list' | 'remove'; + asset: 'upload' | 'getPublicURL'; +}; + +export type SeaTable = AllEntities; + +export type SeaTableRow = Entity; +export type SeaTableBase = Entity; +export type SeaTableLink = Entity; +export type SeaTableAsset = Entity; + +export type RowProperties = PropertiesOf; +export type BaseProperties = PropertiesOf; +export type LinkProperties = PropertiesOf; +export type AssetProperties = PropertiesOf; + +import type { + 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 | number | undefined; +} + +export interface IRowObject { + [name: string]: TColumnValue | object; +} + +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; + workspace_id: number; + dtable_name: string; +} + +export interface ICtx { + base?: IBase; + credentials?: ICredential; +} + +// response object of SQL-Query! +export interface IRowResponse { + metadata: [ + { + key: string; + name: string; + type: string; + }, + ]; + results: IRow[]; +} + +// das ist bad +export interface IRowResponse2 { + rows: IRow[]; +} + +/** neu von mir **/ + +// response object of SQL-Query! +export interface ISqlQueryResult { + metadata: [ + { + key: string; + name: string; + }, + ]; + results: IRow[]; +} + +// response object of GetMetadata +export interface IGetMetadataResult { + metadata: IDtableMetadata; +} + +// response object of GetRows +export interface IGetRowsResult { + rows: IRow[]; +} + +export interface ICollaboratorsResult { + user_list: ICollaborator[]; +} + +export interface ICollaborator { + email: string; + name: string; + contact_email: string; + avatar_url?: string; + id_in_org?: string; +} + +export interface IColumnDigitalSignature { + username: string; + sign_image_url: string; + sign_time: string; + contact_email?: string; + name: string; +} + +export interface IFile { + name: string; + size: number; + type: 'file'; + url: string; + path?: string; +} + +export interface ILinkData { + table_id: string; + other_table_id: string; + link_id: string; +} + +export interface IUploadLink { + upload_link: string; + parent_path: string; + img_relative_path: string; + file_relative_path: string; +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/SeaTable.node.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/SeaTable.node.ts new file mode 100644 index 0000000000..33521ed811 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/SeaTable.node.ts @@ -0,0 +1,58 @@ +import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow'; + +import * as asset from './asset'; +import * as base from './base'; +import * as link from './link'; +import * as row from './row'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'SeaTable', + name: 'seaTable', + icon: 'file:seaTable.svg', + group: ['output'], + version: 2, + subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', + description: 'Consume the SeaTable API', + defaults: { + name: 'SeaTable', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Row', + value: 'row', + }, + { + name: 'Base', + value: 'base', + }, + { + name: 'Link', + value: 'link', + }, + { + name: 'Asset', + value: 'asset', + }, + ], + default: 'row', + }, + ...row.descriptions, + ...base.descriptions, + ...link.descriptions, + ...asset.descriptions, + ], +}; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL.operation.ts new file mode 100644 index 0000000000..0afd9e8931 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL.operation.ts @@ -0,0 +1,48 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; + +const properties: INodeProperties[] = [ + { + displayName: 'Asset Path', + name: 'assetPath', + type: 'string', + placeholder: '/images/2023-09/logo.png', + required: true, + default: '', + }, +]; + +const displayOptions = { + show: { + resource: ['asset'], + operation: ['getPublicURL'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const assetPath = this.getNodeParameter('assetPath', index) as string; + + let responseData = [] as IDataObject[]; + if (assetPath) { + responseData = await seaTableApiRequest.call( + this, + {}, + 'GET', + `/api/v2.1/dtable/app-download-link/?path=${assetPath}`, + ); + } + + return this.helpers.returnJsonArray(responseData); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/asset/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/index.ts new file mode 100644 index 0000000000..d42ad3f2e5 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/index.ts @@ -0,0 +1,37 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as getPublicURL from './getPublicURL.operation'; +import * as upload from './upload.operation'; + +export { upload, getPublicURL }; + +export const descriptions: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['asset'], + }, + }, + options: [ + { + name: 'Public URL', + value: 'getPublicURL', + description: 'Get the public URL from asset path', + action: 'Get the public URL from asset path', + }, + { + name: 'Upload', + value: 'upload', + description: 'Add a file/image to an existing row', + action: 'Upload a file or image', + }, + ], + default: 'upload', + }, + ...upload.description, + ...getPublicURL.description, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload.operation.ts new file mode 100644 index 0000000000..fee1671db5 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload.operation.ts @@ -0,0 +1,232 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; +import type { IUploadLink, IRowObject } from '../Interfaces'; + +const properties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'Choose from the list, or specify a name using an expression', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Column Name', + name: 'uploadColumn', + type: 'options', + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getAssetColumns', + }, + required: true, + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'Choose from the list, or specify the name using an expression', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Row ID', + name: 'rowId', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + required: true, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getRowIds', + }, + default: '', + }, + { + displayName: 'Property Name', + name: 'dataPropertyName', + type: 'string', + default: 'data', + required: true, + description: 'Name of the binary property which contains the data for the file to be written', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Replace Existing File', + name: 'replace', + type: 'boolean', + default: true, + description: + 'Whether to replace the existing asset with the same name (true). Otherwise, a new version with a different name (numeral in parentheses) will be uploaded (false).', + }, + { + displayName: 'Append to Column', + name: 'append', + type: 'boolean', + default: true, + description: + 'Whether to keep existing files/images in the column and append the new asset (true). Otherwise, the existing files/images are removed from the column (false).', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['asset'], + operation: ['upload'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const uploadColumn = this.getNodeParameter('uploadColumn', index) as string; + const uploadColumnType = uploadColumn.split(':::')[1]; + const uploadColumnName = uploadColumn.split(':::')[0]; + const dataPropertyName = this.getNodeParameter('dataPropertyName', index); + const tableName = this.getNodeParameter('tableName', index) as string; + const rowId = this.getNodeParameter('rowId', index) as string; + const uploadLink = (await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api/v2.1/dtable/app-upload-link/', + )) as IUploadLink; + const relativePath = + uploadColumnType === 'image' ? uploadLink.img_relative_path : uploadLink.file_relative_path; + + const options = this.getNodeParameter('options', index) as IDataObject; + + // get server url + const credentials: any = await this.getCredentials('seaTableApi'); + const serverURL: string = credentials.domain + ? credentials.domain.replace(/\/$/, '') + : 'https://cloud.seatable.io'; + + // get workspaceId + const workspaceId = ( + await this.helpers.httpRequest({ + headers: { + Authorization: `Token ${credentials.token}`, + }, + url: `${serverURL}/api/v2.1/dtable/app-access-token/`, + json: true, + }) + ).workspace_id; + + // if there are already assets attached to the column + let existingAssetArray = []; + const append = options.append ?? true; + if (append) { + const rowToUpdate = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/' + rowId, + {}, + { + table_name: tableName, + convert_keys: true, + }, + ); + existingAssetArray = rowToUpdate[uploadColumnName] ?? []; + } + + // Get the binary data and prepare asset for upload + const fileBufferData = await this.helpers.getBinaryDataBuffer(index, dataPropertyName); + const binaryData = this.helpers.assertBinaryData(index, dataPropertyName); + const requestOptions = { + formData: { + file: { + value: fileBufferData, + options: { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }, + }, + parent_dir: uploadLink.parent_path, + replace: options.replace ? '1' : '0', + relative_path: relativePath, + }, + }; + + // Send the upload request + const uploadAsset = await seaTableApiRequest.call( + this, + {}, + 'POST', + `/seafhttp/upload-api/${uploadLink.upload_link.split('seafhttp/upload-api/')[1]}?ret-json=true`, + {}, + {}, + '', + requestOptions, + ); + + // attach the asset to a column in a base + for (let c = 0; c < uploadAsset.length; c++) { + const rowInput = {} as IRowObject; + + const filePath = `${serverURL}/workspace/${workspaceId}${uploadLink.parent_path}/${relativePath}/${uploadAsset[c].name}`; + + if (uploadColumnType === 'image') { + rowInput[uploadColumnName] = [filePath]; + } else if (uploadColumnType === 'file') { + rowInput[uploadColumnName] = uploadAsset; + uploadAsset[c].type = 'file'; + uploadAsset[c].url = filePath; + } + + // merge with existing assets in this column or with [] and remove duplicates + const mergedArray = existingAssetArray.concat(rowInput[uploadColumnName]); + + // Remove duplicates from input, keeping the last one + const uniqueAssets = Array.from(new Set(mergedArray)); + + // Update the rowInput with the unique assets and store into body.row. + rowInput[uploadColumnName] = uniqueAssets; + const body = { + table_name: tableName, + updates: [ + { + row_id: rowId, + row: rowInput, + }, + ], + } as IDataObject; + + // attach assets to table row + const responseData = await seaTableApiRequest.call( + this, + {}, + 'PUT', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/', + body, + ); + + uploadAsset[c].upload_successful = responseData.success; + } + + return this.helpers.returnJsonArray(uploadAsset as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator.operation.ts new file mode 100644 index 0000000000..116086230b --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator.operation.ts @@ -0,0 +1,54 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; +import type { ICollaborator } from '../Interfaces'; + +export const properties: INodeProperties[] = [ + { + displayName: 'Name or email of the collaborator', + name: 'searchString', + type: 'string', + placeholder: 'Enter the name or the email or the collaborator', + required: true, + default: '', + description: + 'SeaTable identifies users with a unique username like 244b43hr6fy54bb4afa2c2cb7369d244@auth.local. Get this username from an email or the name of a collaborator.', + }, +]; + +const displayOptions = { + show: { + resource: ['base'], + operation: ['collaborator'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const searchString = this.getNodeParameter('searchString', index) as string; + + const collaboratorsResult = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/related-users/', + ); + const collaborators = collaboratorsResult.user_list || []; + + const data = collaborators.filter( + (col: ICollaborator) => + col.contact_email.includes(searchString) || col.name.includes(searchString), + ); + + return this.helpers.returnJsonArray(data as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/index.ts new file mode 100644 index 0000000000..a1ac51f1cb --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/index.ts @@ -0,0 +1,45 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as collaborator from './collaborator.operation'; +import * as metadata from './metadata.operation'; +import * as snapshot from './snapshot.operation'; + +export { snapshot, metadata, collaborator }; + +export const descriptions: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['base'], + }, + }, + options: [ + { + name: 'Snapshot', + value: 'snapshot', + description: 'Create a snapshot of the base', + action: 'Create a snapshot', + }, + { + name: 'Metadata', + value: 'metadata', + description: 'Get the complete metadata of the base', + action: 'Get metadata of a base', + }, + { + name: 'Collaborator', + value: 'collaborator', + description: 'Get the username from the email or name of a collaborator', + action: 'Get username from email or name', + }, + ], + default: 'snapshot', + }, + ...snapshot.description, + ...metadata.description, + ...collaborator.description, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata.operation.ts new file mode 100644 index 0000000000..1bd0d11298 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata.operation.ts @@ -0,0 +1,30 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; + +export const properties: INodeProperties[] = []; + +const displayOptions = { + show: { + resource: ['base'], + operation: ['metadata'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions): Promise { + const responseData = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata/', + ); + return this.helpers.returnJsonArray(responseData.metadata as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot.operation.ts new file mode 100644 index 0000000000..27bb670eb1 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot.operation.ts @@ -0,0 +1,32 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; + +export const properties: INodeProperties[] = []; + +const displayOptions = { + show: { + resource: ['base'], + operation: ['snapshot'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions): Promise { + const responseData = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/snapshot/', + { dtable_name: 'snapshot' }, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/add.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/add.operation.ts new file mode 100644 index 0000000000..197a7e4629 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/add.operation.ts @@ -0,0 +1,96 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; + +export const properties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Table Name (Source)', + name: 'tableName', + type: 'options', + placeholder: 'Name of table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNameAndId', + }, + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'Choose from the list, of specify by using an expression. Provide it in the way "table_name:::table_id".', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Link Column', + name: 'linkColumn', + type: 'options', + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getLinkColumns', + }, + required: true, + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'Choose from the list of specify the Link Column by using an expression. You have to provide it in the way "column_name:::link_id:::other_table_id".', + }, + { + displayName: 'Row ID From the Source Table', + name: 'linkColumnSourceId', + type: 'string', + required: true, + default: '', + description: 'Provide the row ID of table you selected', + }, + { + displayName: 'Row ID From the Target', + name: 'linkColumnTargetId', + type: 'string', + required: true, + default: '', + description: 'Provide the row ID of table you want to link', + }, +]; + +const displayOptions = { + show: { + resource: ['link'], + operation: ['add'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const tableName = this.getNodeParameter('tableName', index) as string; + const linkColumn = this.getNodeParameter('linkColumn', index) as any; + const linkColumnSourceId = this.getNodeParameter('linkColumnSourceId', index) as string; + const linkColumnTargetId = this.getNodeParameter('linkColumnTargetId', index) as string; + + const body = { + link_id: linkColumn.split(':::')[1], + table_id: tableName.split(':::')[1], + other_table_id: linkColumn.split(':::')[2], + other_rows_ids_map: { + [linkColumnSourceId]: [linkColumnTargetId], + }, + }; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/links/', + body, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/index.ts new file mode 100644 index 0000000000..1864e9fde3 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/index.ts @@ -0,0 +1,45 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as add from './add.operation'; +import * as list from './list.operation'; +import * as remove from './remove.operation'; + +export { add, list, remove }; + +export const descriptions: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['link'], + }, + }, + options: [ + { + name: 'Add', + value: 'add', + description: 'Create a link between two rows in a link column', + action: 'Add a row link', + }, + { + name: 'List', + value: 'list', + description: 'List all links of a specific row', + action: 'List row links', + }, + { + name: 'Remove', + value: 'remove', + description: 'Remove a link between two rows from a link column', + action: 'Remove a row link', + }, + ], + default: 'add', + }, + ...add.description, + ...list.description, + ...remove.description, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/list.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/list.operation.ts new file mode 100644 index 0000000000..41470d28fc --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/list.operation.ts @@ -0,0 +1,92 @@ +/* eslint-disable n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options */ +/* eslint-disable n8n-nodes-base/node-param-description-wrong-for-dynamic-options */ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; + +export const properties: INodeProperties[] = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNameAndId', + }, + default: '', + description: + 'Choose from the list, of specify by using an expression. Provide it in the way "table_name:::table_id".', + }, + { + displayName: 'Link Column', + name: 'linkColumn', + type: 'options', + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getLinkColumnsWithColumnKey', + }, + required: true, + default: '', + description: + 'Choose from the list of specify the Link Column by using an expression. You have to provide it in the way "column_name:::link_id:::other_table_id:::column_key".', + }, + { + displayName: 'Row ID', + name: 'rowId', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + required: true, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getRowIds', + }, + default: '', + }, +]; + +const displayOptions = { + show: { + resource: ['link'], + operation: ['list'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + // get parameters + const tableName = this.getNodeParameter('tableName', index) as string; + const linkColumn = this.getNodeParameter('linkColumn', index) as string; + const rowId = this.getNodeParameter('rowId', index) as string; + + // get rows + const responseData = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/query-links/', + { + table_id: tableName.split(':::')[1], + link_column_key: linkColumn.split(':::')[3], + rows: [ + { + row_id: rowId, + offset: 0, + limit: 100, + }, + ], + }, + ); + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove.operation.ts new file mode 100644 index 0000000000..eea8c4969e --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove.operation.ts @@ -0,0 +1,94 @@ +/* eslint-disable n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options */ +/* eslint-disable n8n-nodes-base/node-param-description-wrong-for-dynamic-options */ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; + +export const properties: INodeProperties[] = [ + { + displayName: 'Table Name (Source)', + name: 'tableName', + type: 'options', + placeholder: 'Name of table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNameAndId', + }, + default: '', + description: + 'Choose from the list, of specify by using an expression. Provide it in the way "table_name:::table_id".', + }, + { + displayName: 'Link Column', + name: 'linkColumn', + type: 'options', + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getLinkColumns', + }, + required: true, + default: '', + description: + 'Choose from the list of specify the Link Column by using an expression. You have to provide it in the way "column_name:::link_id:::other_table_id".', + }, + { + displayName: 'Row ID From the Source Table', + name: 'linkColumnSourceId', + type: 'string', + required: true, + default: '', + description: 'Provide the row ID of table you selected', + }, + { + displayName: 'Row ID From the Target Table', + name: 'linkColumnTargetId', + type: 'string', + required: true, + default: '', + description: 'Provide the row ID of table you want to link', + }, +]; + +const displayOptions = { + show: { + resource: ['link'], + operation: ['remove'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const tableName = this.getNodeParameter('tableName', index) as string; + const linkColumn = this.getNodeParameter('linkColumn', index) as any; + const linkColumnSourceId = this.getNodeParameter('linkColumnSourceId', index) as string; + const linkColumnTargetId = this.getNodeParameter('linkColumnTargetId', index) as string; + + const body = { + link_id: linkColumn.split(':::')[1], + table_id: tableName.split(':::')[1], + other_table_id: linkColumn.split(':::')[2], + other_rows_ids_map: { + [linkColumnSourceId]: [linkColumnTargetId], + }, + }; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'DELETE', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/links/', + body, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/router.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/router.ts new file mode 100644 index 0000000000..11b306de39 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/router.ts @@ -0,0 +1,53 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; + +import * as asset from './asset'; +import * as base from './base'; +import type { SeaTable } from './Interfaces'; +import * as link from './link'; +import * as row from './row'; + +export async function router(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const operationResult: INodeExecutionData[] = []; + let responseData: IDataObject | IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + const resource = this.getNodeParameter('resource', i); + const operation = this.getNodeParameter('operation', i); + + const seatable = { + resource, + operation, + } as SeaTable; + + try { + if (seatable.resource === 'row') { + responseData = await row[seatable.operation].execute.call(this, i); + } else if (seatable.resource === 'base') { + responseData = await base[seatable.operation].execute.call(this, i); + } else if (seatable.resource === 'link') { + responseData = await link[seatable.operation].execute.call(this, i); + } else if (seatable.resource === 'asset') { + responseData = await asset[seatable.operation].execute.call(this, i); + } + + const executionData = this.helpers.constructExecutionMetaData( + responseData as INodeExecutionData[], + { + itemData: { item: i }, + }, + ); + + operationResult.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + operationResult.push({ json: this.getInputData(i)[0].json, error }); + } else { + if (error.context) error.context.itemIndex = i; + throw error; + } + } + } + + return [operationResult]; +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/create.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/create.operation.ts new file mode 100644 index 0000000000..049caa159d --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/create.operation.ts @@ -0,0 +1,219 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { + seaTableApiRequest, + getTableColumns, + split, + rowExport, + updateAble, + splitStringColumnsToArrays, +} from '../../GenericFunctions'; +import type { TColumnValue, TColumnsUiValues } from '../../types'; +import type { IRowObject } from '../Interfaces'; + +export const properties: INodeProperties[] = [ + { + 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', + }, + ], + default: 'defineBelow', + description: 'Whether to insert the input data this node receives in the new row', + }, + { + displayName: 'Apply Column Default Values', + name: 'apply_default', + type: 'boolean', + default: false, + description: + 'Whether to use the column default values to populate new rows during creation (only available for normal backend)', + displayOptions: { + show: { + bigdata: [false], + }, + }, + }, + { + displayName: + 'In this mode, make sure the incoming data fields are named the same as the columns in SeaTable. (Use an "Edit Fields" node before this node to change them if required.)', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + '/fieldsToSend': ['autoMapInputData'], + }, + }, + }, + { + displayName: 'Inputs to Ignore', + name: 'inputsToIgnore', + type: 'string', + default: '', + description: + 'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.', + placeholder: 'Enter properties...', + displayOptions: { + show: { + '/fieldsToSend': ['autoMapInputData'], + }, + }, + }, + { + displayName: 'Columns to Send', + name: 'columnsUi', + placeholder: 'Add Column', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Column to Send', + multipleValues: true, + }, + displayOptions: { + show: { + '/fieldsToSend': ['defineBelow'], + }, + }, + options: [ + { + displayName: 'Column', + name: 'columnValues', + values: [ + { + displayName: 'Column Name or ID', + name: 'columnName', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getTableUpdateAbleColumns', + }, + default: '', + }, + { + displayName: 'Column Value', + name: 'columnValue', + type: 'string', + default: '', + }, + ], + }, + ], + default: {}, + description: + 'Add destination column with its value. Provide the value in this way. Date: YYYY-MM-DD or YYYY-MM-DD hh:mm. Duration: time in seconds. Checkbox: true, on or 1. Multi-Select: comma-separated list.', + }, + { + displayName: 'Save to "Big Data" Backend', + name: 'bigdata', + type: 'boolean', + default: false, + description: + 'Whether write to Big Data backend (true) or not (false). True requires the activation of the Big Data backend in the base.', + }, + { + displayName: + 'Hint: Link, files, images or digital signatures have to be added separately. These column types cannot be set with this node.', + name: 'notice', + type: 'notice', + default: '', + }, +]; + +const displayOptions = { + show: { + resource: ['row'], + operation: ['create'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const tableName = this.getNodeParameter('tableName', index) as string; + const tableColumns = await getTableColumns.call(this, tableName); + const fieldsToSend = this.getNodeParameter('fieldsToSend', index) as + | 'defineBelow' + | 'autoMapInputData'; + const bigdata = this.getNodeParameter('bigdata', index) as boolean; + const apply_default = this.getNodeParameter('apply_default', index, false) as boolean; + + const body = { + table_name: tableName, + rows: {}, + } as IDataObject; + let rowInput = {} as IRowObject; + + // get rowInput, an object of key:value pairs like { Name: 'Promo Action 1', Status: "Draft" }. + if (fieldsToSend === 'autoMapInputData') { + const items = this.getInputData(); + const incomingKeys = Object.keys(items[index].json); + const inputDataToIgnore = split(this.getNodeParameter('inputsToIgnore', index, '') as string); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + rowInput[key] = items[index].json[key] as TColumnValue; + } + } else { + const columns = this.getNodeParameter('columnsUi.columnValues', index, []) as TColumnsUiValues; + for (const column of columns) { + rowInput[column.columnName] = column.columnValue; + } + } + + // only keep key:value pairs for columns that are allowed to update. + rowInput = rowExport(rowInput, updateAble(tableColumns)); + + // string to array: multi-select and collaborators + rowInput = splitStringColumnsToArrays(rowInput, tableColumns); + + // save to big data backend + if (bigdata) { + body.rows = [rowInput]; + const responseData = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/add-archived-rows/', + body, + ); + return this.helpers.returnJsonArray(responseData as IDataObject[]); + } + // save to normal backend + else { + body.rows = [rowInput]; + if (apply_default) { + body.apply_default = true; + } + const responseData = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/', + body, + ); + if (responseData.first_row) { + return this.helpers.returnJsonArray(responseData.first_row as IDataObject[]); + } + return this.helpers.returnJsonArray(responseData as IDataObject[]); + } +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/get.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/get.operation.ts new file mode 100644 index 0000000000..692fa8b62a --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/get.operation.ts @@ -0,0 +1,88 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { + seaTableApiRequest, + enrichColumns, + simplify_new, + getBaseCollaborators, +} from '../../GenericFunctions'; +import type { IRowResponse, IDtableMetadataColumn } from '../Interfaces'; + +export const properties: INodeProperties[] = [ + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Simplify', + name: 'simple', + type: 'boolean', + default: true, + description: + 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'Return Column Names', + name: 'convert', + type: 'boolean', + default: true, + description: 'Whether to return the column keys (false) or the column names (true)', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['row'], + operation: ['get'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + // get parameters + const tableName = this.getNodeParameter('tableName', index) as string; + const rowId = this.getNodeParameter('rowId', index) as string; + const options = this.getNodeParameter('options', index) as IDataObject; + + // get collaborators + const collaborators = await getBaseCollaborators.call(this); + + // get rows + const sqlResult = (await seaTableApiRequest.call( + this, + {}, + 'POST', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql/', + { + sql: `SELECT * FROM \`${tableName}\` WHERE _id = '${rowId}'`, + convert_keys: options.convert ?? true, + }, + )) as IRowResponse; + const metadata = sqlResult.metadata as IDtableMetadataColumn[]; + const rows = sqlResult.results; + + // hide columns like button + rows.map((row) => enrichColumns(row, metadata, collaborators)); + const simple = options.simple ?? true; + // remove columns starting with _ if simple; + if (simple) { + rows.map((row) => simplify_new(row)); + } + + return this.helpers.returnJsonArray(rows as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/index.ts new file mode 100644 index 0000000000..01f47aa3a9 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/index.ts @@ -0,0 +1,84 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as create from './create.operation'; +import * as get from './get.operation'; +import * as list from './list.operation'; +import * as lock from './lock.operation'; +import * as remove from './remove.operation'; +import * as search from './search.operation'; +import { sharedProperties } from './sharedProperties'; +import * as unlock from './unlock.operation'; +import * as update from './update.operation'; + +export { create, get, search, update, remove, lock, unlock, list }; + +export const descriptions: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['row'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new row', + action: 'Create a row', + }, + { + name: 'Delete', + value: 'remove', + description: 'Delete a row', + action: 'Delete a row', + }, + { + name: 'Get', + value: 'get', + description: 'Get the content of a row', + action: 'Get a row', + }, + { + name: 'Get Many', + value: 'list', + description: 'Get many rows from a table or a table view', + action: 'Get many rows', + }, + { + name: 'Lock', + value: 'lock', + description: 'Lock a row to prevent further changes', + action: 'Add a row lock', + }, + { + name: 'Search', + value: 'search', + description: 'Search one or multiple rows', + action: 'Search a row by keyword', + }, + { + name: 'Unlock', + value: 'unlock', + description: 'Remove the lock from a row', + action: 'Remove a row lock', + }, + { + name: 'Update', + value: 'update', + description: 'Update the content of a row', + action: 'Update a row', + }, + ], + default: 'create', + }, + ...sharedProperties, + ...create.description, + ...get.description, + ...list.description, + ...search.description, + ...update.description, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/list.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/list.operation.ts new file mode 100644 index 0000000000..7dc9082502 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/list.operation.ts @@ -0,0 +1,116 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { + seaTableApiRequest, + enrichColumns, + simplify_new, + getBaseCollaborators, +} from '../../GenericFunctions'; +import type { IRow } from '../Interfaces'; + +export const properties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'View Name', + name: 'viewName', + type: 'options', + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getTableViews', + }, + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'The name of SeaTable view to access, or specify by using an expression. Provide it in the way "col.name:::col.type".', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Simplify', + name: 'simple', + type: 'boolean', + default: true, + description: + 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'Return Column Names', + name: 'convert', + type: 'boolean', + default: true, + description: 'Whether to return the column keys (false) or the column names (true)', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['row'], + operation: ['list'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + // get parameters + const tableName = this.getNodeParameter('tableName', index) as string; + const viewName = this.getNodeParameter('viewName', index) as string; + const options = this.getNodeParameter('options', index) as IDataObject; + + // get collaborators + const collaborators = await getBaseCollaborators.call(this); + + // get rows + const requestMeta = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata/', + ); + + const requestRows = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/', + {}, + { + table_name: tableName, + view_name: viewName, + limit: 1000, + convert_keys: options.convert ?? true, + }, + ); + + const metadata = + requestMeta.metadata.tables.find((table: { name: string }) => table.name === tableName) + ?.columns ?? []; + const rows = requestRows.rows as IRow[]; + + // hide columns like button + rows.map((row) => enrichColumns(row, metadata, collaborators)); + + const simple = options.simple ?? true; + // remove columns starting with _ if simple; + if (simple) { + rows.map((row) => simplify_new(row)); + } + + return this.helpers.returnJsonArray(rows as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock.operation.ts new file mode 100644 index 0000000000..44b5f21c06 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock.operation.ts @@ -0,0 +1,24 @@ +import type { IDataObject, INodeExecutionData, IExecuteFunctions } from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const tableName = this.getNodeParameter('tableName', index) as string; + const rowId = this.getNodeParameter('rowId', index) as string; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'PUT', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/lock-rows/', + { + table_name: tableName, + row_ids: [rowId], + }, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove.operation.ts new file mode 100644 index 0000000000..60bd046feb --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove.operation.ts @@ -0,0 +1,26 @@ +import type { IDataObject, INodeExecutionData, IExecuteFunctions } from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const tableName = this.getNodeParameter('tableName', index) as string; + const rowId = this.getNodeParameter('rowId', index) as string; + + const requestBody: IDataObject = { + table_name: tableName, + row_ids: [rowId], + }; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'DELETE', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/', + requestBody, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/search.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/search.operation.ts new file mode 100644 index 0000000000..d0a8f482a0 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/search.operation.ts @@ -0,0 +1,139 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { + seaTableApiRequest, + enrichColumns, + simplify_new, + getBaseCollaborators, +} from '../../GenericFunctions'; +import type { IDtableMetadataColumn, IRowResponse } from '../Interfaces'; + +export const properties: INodeProperties[] = [ + { + displayName: 'Column Name or ID', + name: 'searchColumn', + type: 'options', + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getSearchableColumns', + }, + required: true, + default: '', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'Select the column to be searched. Not all column types are supported for search. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Search Term', + name: 'searchTerm', + type: 'string', + required: true, + default: '', + description: 'What to look for?', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Case Insensitive Search', + name: 'insensitive', + type: 'boolean', + default: false, + description: + 'Whether the search ignores case sensitivity (true). Otherwise, it distinguishes between uppercase and lowercase characters.', + }, + { + displayName: 'Activate Wildcard Search', + name: 'wildcard', + type: 'boolean', + default: true, + description: + 'Whether the search only results perfect matches (true). Otherwise, it finds a row even if the search value is part of a string (false).', + }, + { + displayName: 'Simplify', + name: 'simple', + type: 'boolean', + default: true, + description: + 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'Return Column Names', + name: 'convert', + type: 'boolean', + default: true, + description: 'Whether to return the column keys (false) or the column names (true)', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['row'], + operation: ['search'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const tableName = this.getNodeParameter('tableName', index) as string; + const searchColumn = this.getNodeParameter('searchColumn', index) as string; + const searchTerm = this.getNodeParameter('searchTerm', index) as string | number; + let searchTermString = String(searchTerm); + const options = this.getNodeParameter('options', index) as IDataObject; + + // get collaborators + const collaborators = await getBaseCollaborators.call(this); + + // this is the base query. The WHERE has to be finalized... + let sqlQuery = `SELECT * FROM \`${tableName}\` WHERE \`${searchColumn}\``; + + if (options.insensitive) { + searchTermString = searchTermString.toLowerCase(); + sqlQuery = `SELECT * FROM \`${tableName}\` WHERE lower(\`${searchColumn}\`)`; + } + + const wildcard = options.wildcard ?? true; + + if (wildcard) sqlQuery = sqlQuery + ' LIKE "%' + searchTermString + '%"'; + else if (!wildcard) sqlQuery = sqlQuery + ' = "' + searchTermString + '"'; + + const sqlResult = (await seaTableApiRequest.call( + this, + {}, + 'POST', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql', + { + sql: sqlQuery, + convert_keys: options.convert ?? true, + }, + )) as IRowResponse; + const metadata = sqlResult.metadata as IDtableMetadataColumn[]; + const rows = sqlResult.results; + + // hide columns like button + rows.map((row) => enrichColumns(row, metadata, collaborators)); + + // remove columns starting with _; + if (options.simple) { + rows.map((row) => simplify_new(row)); + } + + return this.helpers.returnJsonArray(rows as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/sharedProperties.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/sharedProperties.ts new file mode 100644 index 0000000000..cbf1d9c845 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/sharedProperties.ts @@ -0,0 +1,45 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const sharedProperties: INodeProperties[] = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['row'], + }, + }, + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Row ID', + name: 'rowId', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + required: true, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getRowIds', + }, + default: '', + displayOptions: { + show: { + resource: ['row'], + }, + hide: { + operation: ['create', 'list', 'search'], + }, + }, + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock.operation.ts new file mode 100644 index 0000000000..3608ff4169 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock.operation.ts @@ -0,0 +1,24 @@ +import type { IDataObject, INodeExecutionData, IExecuteFunctions } from 'n8n-workflow'; + +import { seaTableApiRequest } from '../../GenericFunctions'; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const tableName = this.getNodeParameter('tableName', index) as string; + const rowId = this.getNodeParameter('rowId', index) as string; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'PUT', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/unlock-rows/', + { + table_name: tableName, + row_ids: [rowId], + }, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/update.operation.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/update.operation.ts new file mode 100644 index 0000000000..a920e5e503 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/update.operation.ts @@ -0,0 +1,173 @@ +import { + type IDataObject, + type INodeExecutionData, + type INodeProperties, + type IExecuteFunctions, + updateDisplayOptions, +} from 'n8n-workflow'; + +import { + seaTableApiRequest, + getTableColumns, + split, + rowExport, + updateAble, + splitStringColumnsToArrays, +} from '../../GenericFunctions'; +import type { TColumnsUiValues, TColumnValue } from '../../types'; +import type { IRowObject } from '../Interfaces'; + +export const properties: INodeProperties[] = [ + { + 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', + }, + ], + 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: { + resource: ['row'], + operation: ['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 or ID', + name: 'columnName', + type: 'options', + description: + 'Choose from the list, or specify an ID using an expression', + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getTableUpdateAbleColumns', + }, + default: '', + }, + { + displayName: 'Column Value', + name: 'columnValue', + type: 'string', + default: '', + }, + ], + }, + ], + displayOptions: { + show: { + resource: ['row'], + operation: ['update'], + fieldsToSend: ['defineBelow'], + }, + }, + default: {}, + description: + 'Add destination column with its value. Provide the value in this way:Date: YYYY-MM-DD or YYYY-MM-DD hh:mmDuration: time in secondsCheckbox: true, on or 1Multi-Select: comma-separated list.', + }, + { + displayName: 'Hint: Link, files, images or digital signatures have to be added separately.', + name: 'notice', + type: 'notice', + default: '', + }, +]; + +const displayOptions = { + show: { + resource: ['row'], + operation: ['update'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const tableName = this.getNodeParameter('tableName', index) as string; + const tableColumns = await getTableColumns.call(this, tableName); + const fieldsToSend = this.getNodeParameter('fieldsToSend', index) as + | 'defineBelow' + | 'autoMapInputData'; + const rowId = this.getNodeParameter('rowId', index) as string; + + let rowInput = {} as IRowObject; + + // get rowInput, an object of key:value pairs like { Name: 'Promo Action 1', Status: "Draft" }. + if (fieldsToSend === 'autoMapInputData') { + const items = this.getInputData(); + const incomingKeys = Object.keys(items[index].json); + const inputDataToIgnore = split(this.getNodeParameter('inputsToIgnore', index, '') as string); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + rowInput[key] = items[index].json[key] as TColumnValue; + } + } else { + const columns = this.getNodeParameter('columnsUi.columnValues', index, []) as TColumnsUiValues; + for (const column of columns) { + rowInput[column.columnName] = column.columnValue; + } + } + + // only keep key:value pairs for columns that are allowed to update. + rowInput = rowExport(rowInput, updateAble(tableColumns)); + + // string to array: multi-select and collaborators + rowInput = splitStringColumnsToArrays(rowInput, tableColumns); + + const body = { + table_name: tableName, + updates: [ + { + row_id: rowId, + row: rowInput, + }, + ], + } as IDataObject; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'PUT', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/', + body, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/versionDescription.ts new file mode 100644 index 0000000000..8b25d0194e --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/versionDescription.ts @@ -0,0 +1,59 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow'; + +import * as asset from './asset'; +import * as base from './base'; +import * as link from './link'; +import * as row from './row'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'SeaTable', + name: 'seaTable', + icon: 'file:seaTable.svg', + group: ['output'], + version: 2, + subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', + description: 'Consume the SeaTable API', + defaults: { + name: 'SeaTable', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Row', + value: 'row', + }, + { + name: 'Base', + value: 'base', + }, + { + name: 'Link', + value: 'link', + }, + { + name: 'Asset', + value: 'asset', + }, + ], + default: 'row', + }, + ...row.descriptions, + ...base.descriptions, + ...link.descriptions, + ...asset.descriptions, + ], +}; diff --git a/packages/nodes-base/nodes/SeaTable/v2/methods/index.ts b/packages/nodes-base/nodes/SeaTable/v2/methods/index.ts new file mode 100644 index 0000000000..65ff6192a3 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/methods/index.ts @@ -0,0 +1 @@ +export * as loadOptions from './loadOptions'; diff --git a/packages/nodes-base/nodes/SeaTable/v2/methods/loadOptions.ts b/packages/nodes-base/nodes/SeaTable/v2/methods/loadOptions.ts new file mode 100644 index 0000000000..8ec49238ea --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/methods/loadOptions.ts @@ -0,0 +1,280 @@ +import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; + +import type { IRow } from '../actions/Interfaces'; +import { getTableColumns, seaTableApiRequest, updateAble } from '../GenericFunctions'; + +export async function getTableNames(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { + metadata: { tables }, + } = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata', + ); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table.name, + }); + } + return returnData; +} + +export async function getTableNameAndId( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodePropertyOptions[] = []; + const { + metadata: { tables }, + } = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata', + ); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table.name + ':::' + table._id, + }); + } + return returnData; +} + +export async function getSearchableColumns( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodePropertyOptions[] = []; + const tableName = this.getCurrentNodeParameter('tableName') as string; + if (tableName) { + const columns = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns', + {}, + { table_name: tableName }, + ); + for (const col of columns.columns) { + if ( + col.type === 'text' || + col.type === 'long-text' || + col.type === 'number' || + col.type === 'single-select' || + col.type === 'email' || + col.type === 'url' || + col.type === 'rate' || + col.type === 'formula' + ) { + returnData.push({ + name: col.name, + value: col.name, + }); + } + } + } + return returnData; +} + +export async function getLinkColumns(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const table = this.getCurrentNodeParameter('tableName') as string; + + const tableName = table.split(':::')[0]; + const tableId = table.split(':::')[1]; + + if (tableName) { + const columns = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns', + {}, + { table_name: tableName }, + ); + for (const col of columns.columns) { + if (col.type === 'link') { + // make sure that the "other table id" is returned and not the same table id again. + const otid = + tableId !== col.data.other_table_id ? col.data.other_table_id : col.data.table_id; + + returnData.push({ + name: col.name, + value: col.name + ':::' + col.data.link_id + ':::' + otid, + }); + } + } + } + return returnData; +} + +export async function getLinkColumnsWithColumnKey( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodePropertyOptions[] = []; + const table = this.getCurrentNodeParameter('tableName') as string; + + const tableName = table.split(':::')[0]; + const tableId = table.split(':::')[1]; + + if (tableName) { + const columns = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns', + {}, + { table_name: tableName }, + ); + for (const col of columns.columns) { + if (col.type === 'link') { + // make sure that the "other table id" is returned and not the same table id again. + const otid = + tableId !== col.data.other_table_id ? col.data.other_table_id : col.data.table_id; + + returnData.push({ + name: col.name, + value: col.name + ':::' + col.data.link_id + ':::' + otid + ':::' + col.key, + }); + } + } + } + return returnData; +} + +export async function getAssetColumns( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodePropertyOptions[] = []; + const tableName = this.getCurrentNodeParameter('tableName') as string; + if (tableName) { + const columns = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns', + {}, + { table_name: tableName }, + ); + for (const col of columns.columns) { + if (col.type === 'image' || col.type === 'file') { + returnData.push({ + name: col.name, + value: col.name + ':::' + col.type, + }); + } + } + } + return returnData; +} + +export async function getSignatureColumns( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodePropertyOptions[] = []; + const tableName = this.getCurrentNodeParameter('tableName') as string; + if (tableName) { + // only execute if table is selected + const columns = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns', + {}, + { table_name: tableName }, + ); + for (const col of columns.columns) { + if (col.type === 'digital-sign') { + returnData.push({ + name: col.name, + value: col.name, + }); + } + } + } + return returnData; +} + +export async function getTableUpdateAbleColumns( + this: ILoadOptionsFunctions, +): Promise { + const tableName = this.getNodeParameter('tableName') as string; + let columns = await getTableColumns.call(this, tableName); + + columns = updateAble(columns); + + return columns + .filter((column) => column.editable) + .map((column) => ({ name: column.name, value: column.name })); +} + +export async function getRowIds(this: ILoadOptionsFunctions): Promise { + const table = this.getCurrentNodeParameter('tableName') as string; + const operation = this.getCurrentNodeParameter('operation') as string; + let tableName = table; + + if (table.indexOf(':::') !== -1) { + tableName = table.split(':::')[0]; + } + + let lockQuery = ''; + + if (operation === 'lock') { + lockQuery = 'WHERE _locked is null'; + } + + if (operation === 'unlock') { + lockQuery = 'WHERE _locked = true'; + } + + const returnData: INodePropertyOptions[] = []; + if (tableName) { + const sqlResult = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql', + { + sql: `SELECT * FROM \`${tableName}\` ${lockQuery} LIMIT 1000`, + convert_keys: false, + }, + ); + const rows = sqlResult.results as IRow[]; + + for (const row of rows) { + returnData.push({ + name: `${row['0000'] as string} (${row._id})`, + value: row._id, + }); + } + } + return returnData; +} + +export async function getTableViews(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const tableName = this.getCurrentNodeParameter('tableName') as string; + if (tableName) { + const { views } = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api-gateway/api/v2/dtables/{{dtable_uuid}}/views', + {}, + { table_name: tableName }, + ); + returnData.push({ + name: '', + value: '', + }); + for (const view of views) { + returnData.push({ + name: view.name, + value: view.name, + }); + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/types.ts b/packages/nodes-base/nodes/SeaTable/v2/types.ts new file mode 100644 index 0000000000..c27f9ca49d --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/types.ts @@ -0,0 +1,100 @@ +// ---------------------------------- +// SeaTable +// ---------------------------------- + +export type TSeaTableServerVersion = '2.0.6'; +export type TSeaTableServerEdition = 'enterprise edition'; + +// ---------------------------------- +// dtable +// ---------------------------------- + +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; + +import type { + IDtableMetadataColumn, + IDtableMetadataTable, + TDtableViewColumn, +} from './actions/Interfaces'; + +export type TColumnType = + | 'text' + | 'long-text' + | 'number' + | 'collaborator' + | 'date' + | 'duration' + | 'single-select' + | 'multiple-select' + | 'image' + | 'file' + | 'email' + | 'url' + | 'checkbox' + | 'rate' + | 'formula' + | 'link-formula' + | 'geolocation' + | 'link' + | 'creator' + | 'ctime' + | 'last-modifier' + | 'mtime' + | 'auto-number' + | 'button' + | 'digital-sign'; + +export type TInheritColumnKey = + | '_id' + | '_creator' + | '_ctime' + | '_last_modifier' + | '_mtime' + | '_seq' + | '_archived' + | '_locked' + | '_locked_by'; + +export type TColumnValue = undefined | boolean | number | string | string[] | null; +export type TColumnKey = TInheritColumnKey | string; + +export type TDtableMetadataTables = readonly IDtableMetadataTable[]; +export type TDtableMetadataColumns = IDtableMetadataColumn[]; +export type TDtableViewColumns = readonly TDtableViewColumn[]; + +// ---------------------------------- +// api +// ---------------------------------- + +export type TEndpointVariableName = 'access_token' | 'dtable_uuid' | 'server'; + +// Template Literal Types requires-ts-4.1.5 -- deferred +export type TMethod = 'GET' | 'POST'; +type TEndpoint = + | '/api/v2.1/dtable/app-access-token/' + | '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/'; +export type TEndpointExpr = TEndpoint; +export type TEndpointResolvedExpr = + TEndpoint; /* deferred: but already in use for header values, e.g. authentication */ + +export type TDateTimeFormat = 'YYYY-MM-DDTHH:mm:ss.SSSZ' /* moment.js */; + +// ---------------------------------- +// node +// ---------------------------------- + +export type TCredentials = ICredentialDataDecryptedObject | undefined; + +export type TTriggerOperation = 'create' | 'update'; + +export type TOperation = 'append' | 'list' | 'metadata'; + +export type TLoadedResource = { + name: string; +}; +export type TColumnsUiValues = Array<{ + columnName: string; + columnValue: string; +}>; + +export type APITypes = 'GET' | 'POST' | 'DELETE' | 'PUT';