diff --git a/packages/nodes-base/credentials/SeaTableApi.credentials.ts b/packages/nodes-base/credentials/SeaTableApi.credentials.ts index 1481548510..2aad821fb0 100644 --- a/packages/nodes-base/credentials/SeaTableApi.credentials.ts +++ b/packages/nodes-base/credentials/SeaTableApi.credentials.ts @@ -1,24 +1,10 @@ -import type { ICredentialType, INodeProperties, INodePropertyOptions } from 'n8n-workflow'; - -import moment from 'moment-timezone'; - -// Get options for timezones -const timezones: INodePropertyOptions[] = moment.tz - .countries() - .reduce((tz: INodePropertyOptions[], country: string) => { - const zonesForCountry = moment.tz - .zonesForCountry(country) - .map((zone) => ({ value: zone, name: zone })); - return tz.concat(zonesForCountry); - }, []); +import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow'; export class SeaTableApi implements ICredentialType { name = 'seaTableApi'; - displayName = 'SeaTable API'; - - documentationUrl = 'seaTable'; - + documentationUrl = + 'https://seatable.io/docs/n8n-integration/erstellen-eines-api-tokens-fuer-n8n/?lang=auto'; properties: INodeProperties[] = [ { displayName: 'Environment', @@ -41,7 +27,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'], @@ -52,16 +38,20 @@ 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 a at a time.', typeOptions: { password: true }, default: '', }, - { - displayName: 'Timezone', - name: 'timezone', - type: 'options', - default: '', - description: "Seatable server's timezone", - 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 4f8f55b856..51c9723fa2 100644 --- a/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts +++ b/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts @@ -1,451 +1,26 @@ -import type { - IExecuteFunctions, - IDataObject, - ILoadOptionsFunctions, - INodeExecutionData, - INodePropertyOptions, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; -import { 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 { SeaTableV1 } from './v1/SeaTableV1.node'; +import { SeaTableV2 } from './v2/SeaTableV2.node'; -import { rowFields, rowOperations } from './RowDescription'; +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, + }; -import type { TColumnsUiValues, TColumnValue } from './types'; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new SeaTableV1(), + 2: new SeaTableV2(baseDescription), + }; -import type { ICtx, IRow, IRowObject } from './Interfaces'; - -export class SeaTable implements INodeType { - description: INodeTypeDescription = { - displayName: 'SeaTable', - name: 'seaTable', - icon: 'file:seaTable.svg', - group: ['input'], - version: 1, - subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', - description: 'Consume the SeaTable API', - defaults: { - name: 'SeaTable', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'seaTableApi', - required: true, - }, - ], - properties: [ - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Row', - value: 'row', - }, - ], - default: 'row', - }, - ...rowOperations, - ...rowFields, - ], - }; - - methods = { - loadOptions: { - async getTableNames(this: ILoadOptionsFunctions) { - const returnData: INodePropertyOptions[] = []; - const { - metadata: { tables }, - } = await seaTableApiRequest.call( - this, - {}, - 'GET', - '/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata', - ); - for (const table of tables) { - returnData.push({ - name: table.name, - value: table.name, - }); - } - return returnData; - }, - async getTableIds(this: ILoadOptionsFunctions) { - const returnData: INodePropertyOptions[] = []; - const { - metadata: { tables }, - } = await seaTableApiRequest.call( - this, - {}, - 'GET', - '/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata', - ); - for (const table of tables) { - returnData.push({ - name: table.name, - value: table._id, - }); - } - return returnData; - }, - - async getTableUpdateAbleColumns(this: ILoadOptionsFunctions) { - const tableName = this.getNodeParameter('tableName') as string; - const columns = await getTableColumns.call(this, tableName); - return columns - .filter((column) => column.editable) - .map((column) => ({ name: column.name, value: column.name })); - }, - async getAllSortableColumns(this: ILoadOptionsFunctions) { - const tableName = this.getNodeParameter('tableName') as string; - const columns = await getTableColumns.call(this, tableName); - return columns - .filter( - (column) => - !['file', 'image', 'url', 'collaborator', 'long-text'].includes(column.type), - ) - .map((column) => ({ name: column.name, value: column.name })); - }, - async getViews(this: ILoadOptionsFunctions) { - const tableName = this.getNodeParameter('tableName') as string; - const views = await getTableViews.call(this, tableName); - return views.map((view) => ({ name: view.name, value: view.name })); - }, - }, - }; - - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - const returnData: 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 9ede573334..d27e55144a 100644 --- a/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts +++ b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts @@ -1,23 +1,33 @@ import type { IPollFunctions, - ILoadOptionsFunctions, INodeExecutionData, - INodePropertyOptions, INodeType, INodeTypeDescription, } from 'n8n-workflow'; -import { getColumns, rowFormatColumns, seaTableApiRequest, simplify } from './GenericFunctions'; +import { seaTableApiRequest, simplify_new, enrichColumns } from './v2/GenericFunctions'; -import type { ICtx, IRow, IRowResponse } from './Interfaces'; +import type { + ICtx, + IRow, + IRowResponse, + IGetMetadataResult, + IGetRowsResult, + IDtableMetadataColumn, + ICollaborator, + ICollaboratorsResult, + IColumnDigitalSignature, +} from './v2/actions/Interfaces'; import moment from 'moment'; +import { loadOptions } from './v2/methods'; + export class SeaTableTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'SeaTable Trigger', name: 'seaTableTrigger', - icon: 'file:seaTable.svg', + icon: 'file:seatable.svg', group: ['trigger'], version: 1, description: 'Starts the workflow when SeaTable events occur', @@ -35,6 +45,29 @@ export class SeaTableTrigger implements INodeType { inputs: [], outputs: ['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', + }, { displayName: 'Table Name or ID', name: 'tableName', @@ -48,109 +81,206 @@ export class SeaTableTrigger implements INodeType { 'The name of SeaTable table to access. Choose from the list, or specify an ID using an expression.', }, { - displayName: 'Event', - name: 'event', + displayName: 'View Name or ID (optional)', + name: 'viewName', type: 'options', - options: [ - { - name: 'Row Created', - value: 'rowCreated', - description: 'Trigger on newly created rows', + required: false, + displayOptions: { + show: { + event: ['newRow', 'updatedRow'], }, - // { - // name: 'Row Modified', - // value: 'rowModified', - // description: 'Trigger has recently modified rows', - // }, - ], - default: 'rowCreated', + }, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getTableViews', + }, + default: '', + description: 'The name of SeaTable view to access. Choose from the list, or specify ...', }, { - displayName: 'Simplify', + displayName: 'Signature column', + name: 'assetColumn', + type: 'options', + required: true, + displayOptions: { + show: { + event: ['newAsset'], + }, + }, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getSignatureColumns', + }, + default: '', + description: 'Select the digital-signature column that should be tracked.', + }, + { + displayName: 'Simplify output', name: 'simple', type: 'boolean', default: true, description: - 'Whether to return a simplified version of the response instead of the raw data', + 'Simplified returns only the columns of your base. Non-simplified will return additional columns like _ctime (=creation time), _mtime (=modification time) etc.', + }, + { + displayName: '"Fetch Test Event" returns max. three items of the last hour.', + name: 'notice', + type: 'notice', + default: '', }, ], }; - 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; - }, - }, - }; + methods = { loadOptions }; 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 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 simple = this.getNodeParameter('simple') as boolean; + 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; + const startDate = + this.getMode() === 'manual' + ? moment().utc().subtract(1, 'h').format() + : (webhookData.lastTimeChecked as string); + const endDate = (webhookData.lastTimeChecked = moment().utc().format()); - let rows; + // this is working, even if the columns _mtime and _ctime have other names. Only relevant for newRow / updatedRow. + const filterField = event === 'newRow' ? '_ctime' : '_mtime'; - const filterField = event === 'rowCreated' ? '_ctime' : '_mtime'; + // Difference between getRows and SqlQuery: + // ==================== - const endpoint = '/dtable-db/api/v1/query/{{dtable_uuid}}/'; + // getRows (if view is selected) + // getRows always gets up to 1.000 rows of the selected view. + // getRows delivers only the rows, not the metadata + // no possibility to filter for _ctime or _mtime with the API call. + // Problems, not yet solved: + // if a column is empty, the column is not returned! + // view with more than 1.000 rows will not work! - 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; + // SqlQuery (if no view is selected) + // SqlQuery returns up to 1.000. WHERE by time and ORDER BY _ctime or _mtime is possible. + // SqlQuery returns rows and metadata + + 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 = '/dtable-db/api/v1/query/{{dtable_uuid}}/'; + 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: 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), + ) as IRow[]; + + // split the objects into new lines (not necessary for digital-sign) + const newRows: any = []; + for (const row of rows) { + if (assetColumnType === 'digital-sign') { + let signature = (row[assetColumn] as IColumnDigitalSignature) || []; + if (signature.sign_time) { + if (new Date(signature.sign_time) > new Date(startDate)) { + newRows.push(signature); + } + } + } + } } - let response; + // View => use getRows. + else if (viewName) { + requestMeta = await seaTableApiRequest.call( + this, + ctx, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata/', + ); + requestRows = await seaTableApiRequest.call( + this, + ctx, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/', + {}, + { + table_name: tableName, + view_name: viewName, + limit: limit, + }, + ); - if (rows.metadata && rows.results) { - const columns = getColumns(rows); - if (simple) { - response = simplify(rows, columns); + // I need only metadata of the selected table. + 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 as IRow[]; } else { - response = rows.results; + rows = requestRows.rows.filter( + (obj) => new Date(obj[filterField]) > new Date(startDate), + ) as IRow[]; + } + } + + // No view => use SQL-Query + else { + const endpoint = '/dtable-db/api/v1/query/{{dtable_uuid}}/'; + 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: true, + }); + metadata = sqlResult.metadata as IDtableMetadataColumn[]; + rows = sqlResult.results as IRow[]; + } + + // ========================================= + // => now I have rows and metadata. + + // lets get the collaborators + let collaboratorsResult: ICollaboratorsResult = await seaTableApiRequest.call( + this, + ctx, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/related-users/', + ); + let collaborators: ICollaborator[] = collaboratorsResult.user_list || []; + + if (Array.isArray(rows) && rows.length > 0) { + // remove columns starting with _ if simple; + if (simple) { + rows = rows.map((row) => simplify_new(row)); } - const allColumns = rows.metadata.map((meta) => meta.name); + // enrich column types like {collaborator, creator, last_modifier}, {image, file} + // remove button column from rows + rows = rows.map((row) => enrichColumns(row, metadata, collaborators)); - response = response - //@ts-ignore - .map((row: IRow) => rowFormatColumns(row, allColumns)) - .map((row: IRow) => ({ json: row })); - } - - if (Array.isArray(response) && response.length) { - return [response]; + // prepare for final output + return [this.helpers.returnJsonArray(rows)]; } return null; diff --git a/packages/nodes-base/nodes/SeaTable/seaTable.svg b/packages/nodes-base/nodes/SeaTable/seatable.svg similarity index 100% rename from packages/nodes-base/nodes/SeaTable/seaTable.svg rename to packages/nodes-base/nodes/SeaTable/seatable.svg diff --git a/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts b/packages/nodes-base/nodes/SeaTable/v1/GenericFunctions.ts similarity index 100% rename from packages/nodes-base/nodes/SeaTable/GenericFunctions.ts rename to packages/nodes-base/nodes/SeaTable/v1/GenericFunctions.ts 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 100% rename from packages/nodes-base/nodes/SeaTable/RowDescription.ts rename to packages/nodes-base/nodes/SeaTable/v1/RowDescription.ts 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/SeaTableV1.node.ts b/packages/nodes-base/nodes/SeaTable/v1/SeaTableV1.node.ts new file mode 100644 index 0000000000..058ae00eca --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v1/SeaTableV1.node.ts @@ -0,0 +1,451 @@ +import type { + IExecuteFunctions, + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { + getTableColumns, + getTableViews, + rowExport, + rowFormatColumns, + rowMapKeyToName, + seaTableApiRequest, + setableApiRequestAllItems, + split, + updateAble, +} from './GenericFunctions'; + +import { rowFields, rowOperations } from './RowDescription'; + +import type { TColumnsUiValues, TColumnValue } from './types'; + +import type { ICtx, IRow, IRowObject } from './Interfaces'; + +export class SeaTableV1 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', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Row', + value: 'row', + }, + ], + default: 'row', + }, + ...rowOperations, + ...rowFields, + ], + }; + + methods = { + loadOptions: { + async getTableNames(this: ILoadOptionsFunctions) { + const returnData: INodePropertyOptions[] = []; + const { + metadata: { tables }, + } = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata', + ); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table.name, + }); + } + return returnData; + }, + async getTableIds(this: ILoadOptionsFunctions) { + const returnData: INodePropertyOptions[] = []; + const { + metadata: { tables }, + } = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata', + ); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table._id, + }); + } + return returnData; + }, + + async getTableUpdateAbleColumns(this: ILoadOptionsFunctions) { + const tableName = this.getNodeParameter('tableName') as string; + const columns = await getTableColumns.call(this, tableName); + return columns + .filter((column) => column.editable) + .map((column) => ({ name: column.name, value: column.name })); + }, + async getAllSortableColumns(this: ILoadOptionsFunctions) { + const tableName = this.getNodeParameter('tableName') as string; + const columns = await getTableColumns.call(this, tableName); + return columns + .filter( + (column) => + !['file', 'image', 'url', 'collaborator', 'long-text'].includes(column.type), + ) + .map((column) => ({ name: column.name, value: column.name })); + }, + async getViews(this: ILoadOptionsFunctions) { + const tableName = this.getNodeParameter('tableName') as string; + const views = await getTableViews.call(this, tableName); + return views.map((view) => ({ name: view.name, value: view.name })); + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: 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/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..6f57e50efe --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/GenericFunctions.ts @@ -0,0 +1,338 @@ +import type { OptionsWithUri } from 'request'; +import FormData from 'form-data'; + +import type { + IDataObject, + IExecuteFunctions, + ILoadOptionsFunctions, + IPollFunctions, + JsonObject, + IHttpRequestMethods, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import type { TDtableMetadataColumns, TEndpointVariableName } from './types'; + +import { schema } from './Schema'; + +import type { + ICollaborator, + ICollaboratorsResult, + ICredential, + ICtx, + IDtableMetadataColumn, + IEndpointVariables, + IName, + IRow, + IRowObject, + IColumnDigitalSignature, + IFile, +} from './actions/Interfaces'; + +// remove last backslash +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: OptionsWithUri = { + headers: { + Authorization: `Token ${ctx?.credentials?.token}`, + }, + uri: `${resolveBaseUri(ctx)}/api/v2.1/dtable/app-access-token/`, + json: true, + }; + ctx.base = await this.helpers.request(options); +} + +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, expr: string, name: TEndpointVariableName) => { + // I need expr. Why? + 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 | undefined = undefined, + 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: OptionsWithUri = { + 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', + }; + } + + // DEBUG-MODE OR API-REQUESTS + // console.log(options); + + if (Object.keys(body).length === 0) { + delete options.body; + } + + try { + return 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 { + let collaboratorsResult: ICollaboratorsResult = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/related-users/', + ); + let 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', + '/dtable-server/api/v1/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 uniquePredicate = (current: string, index: number, all: string[]) => + all.indexOf(current) === index; +const nonInternalPredicate = (name: string) => !Object.keys(schema.internalNames).includes(name);*/ +const namePredicate = (name: string) => (named: IName) => named.name === name; +export const nameOfPredicate = (names: readonly IName[]) => (name: string) => + names.find(namePredicate(name)); + +const normalize = (subject: string): string => (subject ? subject.normalize() : ''); + +/* will ich diesen call ? */ +export const split = (subject: string): string[] => + normalize(subject) + .split(/\s*((?:[^\\,]*?(?:\\[\s\S])*)*?)\s*(?:,|$)/) + .filter((s) => s.length) + .map((s) => s.replace(/\\([\s\S])/gm, ($0, $1) => $1)); + +// INTERNAL: get collaborator info from @auth.local address +function getCollaboratorInfo( + authLocal: string | null | undefined, + collaboratorList: ICollaborator[], +) { + let collaboratorDetails: ICollaborator; + collaboratorDetails = collaboratorList.find( + (singleCollaborator) => singleCollaborator['email'] === authLocal, + ) || { contact_email: 'unknown', name: 'unkown', email: 'unknown' }; + return collaboratorDetails; +} + +// INTERNAL: split asset path. +function getAssetPath(type: string, url: string) { + const parts = url.split(`/${type}/`); + if (parts[1]) { + return '/' + type + '/' + parts[1]; + } + return url; +} + +// CDB: neu von mir +export function enrichColumns( + row: IRow, + metadata: IDtableMetadataColumn[], + collaboratorList: ICollaborator[], +): IRow { + Object.keys(row).forEach((key) => { + let columnDef = metadata.find((obj) => obj.name === key || obj.key === key); + //console.log(key + " is from type " + columnDef?.type); + + if (columnDef?.type === 'collaborator') { + // collaborator is an array of strings. + let collaborators = (row[key] as string[]) || []; + if (collaborators.length > 0) { + let newArray = collaborators.map((email) => { + let collaboratorDetails = getCollaboratorInfo(email, collaboratorList); + let newColl = { + email: 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. + let collaboratorDetails = getCollaboratorInfo(row[key] as string, collaboratorList); + row[key] = { + email: row[key], + contact_email: collaboratorDetails['contact_email'], + name: collaboratorDetails['name'], + }; + } + + if (columnDef?.type === 'image') { + let pictures = (row[key] as string[]) || []; + if (pictures.length > 0) { + let newArray = pictures.map((url) => ({ + name: url.split('/').pop(), + size: 0, + type: 'image', + url: url, + path: getAssetPath('images', url), + })); + row[key] = newArray; + } + } + + if (columnDef?.type === 'file') { + let files = (row[key] as IFile[]) || []; + files.forEach((file) => { + file.path = getAssetPath('files', file.url); + }); + } + + if (columnDef?.type === 'digital-sign') { + let digitalSignature: IColumnDigitalSignature | any = row[key]; + let 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; +} + +// using create, I input a string like a5adebe279e04415a28b2c7e256e9e8d@auth.local and it should be transformed to an array. +// same with multi-select. +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()); + } + } + }); + return row; +} + +// sollte eher heißen: remove nonUpdateColumnTypes and only use allowed columns! +export function rowExport(row: IRowObject, columns: TDtableMetadataColumns): IRowObject { + let 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/SeaTableV2.node.ts b/packages/nodes-base/nodes/SeaTable/v2/SeaTableV2.node.ts new file mode 100644 index 0000000000..2acbeee4d1 --- /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 { versionDescription } from './actions/versionDescription'; +import { loadOptions } from './methods'; +import { router } from './actions/router'; + +export class SeaTableV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { loadOptions }; + + async execute(this: IExecuteFunctions) { + return 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..50af0f7b6f --- /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'; + base: 'snapshot' | 'metadata' | 'apiCall' | 'collaborator'; + link: 'add' | '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/asset/getPublicURL/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL/description.ts new file mode 100644 index 0000000000..da37de8e69 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL/description.ts @@ -0,0 +1,19 @@ +import type { AssetProperties } from '../../Interfaces'; + +export const assetGetPublicURLDescription: AssetProperties = [ + { + displayName: 'Asset path', + name: 'assetPath', + type: 'string', + placeholder: '/images/2023-09/logo.png', + required: true, + displayOptions: { + show: { + resource: ['asset'], + operation: ['getPublicURL'], + }, + }, + default: '', + description: '', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL/execute.ts new file mode 100644 index 0000000000..d980a2cc21 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL/execute.ts @@ -0,0 +1,21 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; + +export async function getPublicURL( + 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 as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL/index.ts new file mode 100644 index 0000000000..067493a968 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/getPublicURL/index.ts @@ -0,0 +1,4 @@ +import { getPublicURL as execute } from './execute'; +import { assetGetPublicURLDescription as description } from './description'; + +export { description, execute }; 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..661a110d16 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/index.ts @@ -0,0 +1,36 @@ +import * as upload from './upload'; +import * as getPublicURL from './getPublicURL'; +import type { INodeProperties } from 'n8n-workflow'; + +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/image', + }, + ], + default: 'upload', + }, + ...upload.description, + ...getPublicURL.description, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload/description.ts new file mode 100644 index 0000000000..7304cf159f --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload/description.ts @@ -0,0 +1,104 @@ +import type { AssetProperties } from '../../Interfaces'; + +export const assetUploadDescription: AssetProperties = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + show: { + resource: ['asset'], + operation: ['upload'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Column', + name: 'uploadColumn', + type: 'options', + displayOptions: { + show: { + resource: ['asset'], + operation: ['upload'], + }, + }, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getAssetColumns', + }, + required: true, + default: '', + description: 'Select the column for the upload.', + }, + { + displayName: 'Row ID', + name: 'rowId', + type: 'options', + required: true, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getRowIds', + }, + displayOptions: { + show: { + resource: ['asset'], + operation: ['upload'], + }, + }, + default: '', + }, + { + displayName: 'Workspace ID', + name: 'workspaceId', + type: 'number', + typeOptions: { + minValue: 1, + numberStepSize: 1, + }, + required: true, + displayOptions: { + show: { + resource: ['asset'], + operation: ['upload'], + }, + }, + default: '', + description: + 'How to get the workspace ID: https://seatable.io/docs/arbeiten-mit-gruppen/workspace-id-einer-gruppe-ermitteln/?lang=auto', + }, + { + displayName: 'Property Name', + name: 'dataPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + resource: ['asset'], + operation: ['upload'], + }, + }, + description: 'Name of the binary property which contains the data for the file to be written', + }, + /*{ + displayName: 'Replace', + name: 'replace', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: ['asset'], + operation: ['upload'], + }, + }, + description: 'Replace existing file if the file/image already exists with same name.', + },*/ +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload/execute.ts new file mode 100644 index 0000000000..2f3db9677a --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload/execute.ts @@ -0,0 +1,89 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; +import type { IUploadLink, IRowObject } from '../../Interfaces'; + +export async function upload( + this: IExecuteFunctions, + index: number, +): Promise { + // step 1: upload file to base + const uploadColumn = this.getNodeParameter('uploadColumn', index) as any; + const uploadColumnType = uploadColumn.split(':::')[1]; + const dataPropertyName = this.getNodeParameter('dataPropertyName', index) as string; + const tableName = this.getNodeParameter('tableName', index) as string; + const rowId = this.getNodeParameter('rowId', index) as string; + const workspaceId = this.getNodeParameter('workspaceId', index) as string; + const uploadLink = (await seaTableApiRequest.call( + this, + {}, + 'GET', + '/api/v2.1/dtable/app-upload-link/', + )) as IUploadLink; + + // Get the binary data + const fileBufferData = await this.helpers.getBinaryDataBuffer(index, dataPropertyName); + const binaryData = this.helpers.assertBinaryData(index, dataPropertyName); + // Create our request option + const options = { + formData: { + file: { + value: fileBufferData, + options: { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }, + }, + parent_dir: uploadLink.parent_path, + replace: '0', + relative_path: + uploadColumnType === 'image' ? uploadLink.img_relative_path : uploadLink.file_relative_path, + }, + }; + + // Send the request + let uploadAsset = await seaTableApiRequest.call( + this, + {}, + 'POST', + `/seafhttp/upload-api/${uploadLink.upload_link.split('seafhttp/upload-api/')[1]}?ret-json=true`, + {}, + {}, + '', + options, + ); + + // now step 2 (attaching the file to a column in a base) + for (let c = 0; c < uploadAsset.length; c++) { + const body = { + table_name: tableName, + row_id: rowId, + row: {}, + } as IDataObject; + let rowInput = {} as IRowObject; + + const filePath = [ + `/workspace/${workspaceId}${uploadLink.parent_path}/${uploadLink.img_relative_path}/${uploadAsset[c].name}`, + ]; + + if (uploadColumnType === 'image') { + rowInput[uploadColumn.split(':::')[0]] = filePath; + } else if (uploadColumnType === 'file') { + rowInput[uploadColumn.split(':::')[0]] = uploadAsset; + uploadAsset[c].type = 'file'; + uploadAsset[c].url = filePath; + } + body.row = rowInput; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'PUT', + '/dtable-server/api/v1/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/asset/upload/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload/index.ts new file mode 100644 index 0000000000..3f8b338788 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/asset/upload/index.ts @@ -0,0 +1,4 @@ +import { upload as execute } from './execute'; +import { assetUploadDescription as description } from './description'; + +export { description, execute }; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/apiCall/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/apiCall/description.ts new file mode 100644 index 0000000000..8e8f36a669 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/apiCall/description.ts @@ -0,0 +1,136 @@ +import type { BaseProperties } from '../../Interfaces'; + +export const baseApiCallDescription: BaseProperties = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + show: { + resource: ['base'], + operation: ['apiCall'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'HTTP Method', + name: 'apiMethod', + type: 'options', + options: [ + { + name: 'POST', + value: 'POST', + }, + { + name: 'GET', + value: 'GET', + }, + { + name: 'POST', + value: 'POST', + }, + { + name: 'DELETE', + value: 'DELETE', + }, + ], + displayOptions: { + show: { + resource: ['base'], + operation: ['apiCall'], + }, + }, + required: true, + default: '', + }, + { + displayName: 'Hint: The Authentication header is included automatically.', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + resource: ['base'], + operation: ['apiCall'], + }, + }, + }, + { + displayName: 'URL', + name: 'apiEndpoint', + type: 'string', + displayOptions: { + show: { + operation: ['apiCall'], + }, + }, + required: true, + default: '', + placeholder: '/dtable-server/...', + description: + 'The URL has to start with /dtable-server/ or /dtable-db/. All possible requests can be found at the SeaTable API Reference at https://api.seatable.io \ + Please be aware that only request from the section Base Operations that use an Base-Token for the authentication are allowed to use.', + }, + { + displayName: 'Query String Parameters', + name: 'apiParams', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + description: + 'These params will be URL-encoded and appended to the URL when making the request.', + options: [ + { + name: 'apiParamsValues', + displayName: 'Parameters', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + displayOptions: { + show: { + resource: ['base'], + operation: ['apiCall'], + }, + }, + }, + { + displayName: 'Body', + name: 'apiBody', + type: 'json', + displayOptions: { + show: { + resource: ['base'], + operation: ['apiCall'], + }, + }, + typeOptions: { + rows: 4, + }, + default: '', + description: + 'Only valid JSON is accepted. n8n will pass anything you enter as raw input. For example, {"foo", "bar"} is perfectly valid. Of cause you can use variables from n8n inside your JSON.', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/apiCall/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/apiCall/execute.ts new file mode 100644 index 0000000000..8b212b1d87 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/apiCall/execute.ts @@ -0,0 +1,15 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; + +export async function apiCall( + this: IExecuteFunctions, + index: number, +): Promise { + const responseData = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata/', + ); + return this.helpers.returnJsonArray(responseData.metadata as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/apiCall/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/apiCall/index.ts new file mode 100644 index 0000000000..599ffa9511 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/apiCall/index.ts @@ -0,0 +1,4 @@ +import { apiCall as execute } from './execute'; +import { baseApiCallDescription as description } from './description'; + +export { description, execute }; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator/description.ts new file mode 100644 index 0000000000..492df944f1 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator/description.ts @@ -0,0 +1,20 @@ +import type { BaseProperties } from '../../Interfaces'; + +export const baseCollaboratorDescription: BaseProperties = [ + { + displayName: 'Name or email of the collaborator', + name: 'searchString', + type: 'string', + placeholder: 'Enter the name or the email or the collaborator', + required: true, + displayOptions: { + show: { + resource: ['base'], + operation: ['collaborator'], + }, + }, + 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.', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator/execute.ts new file mode 100644 index 0000000000..57878dda9e --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator/execute.ts @@ -0,0 +1,25 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; +import { ICollaborator } from '../../Interfaces'; + +export async function collaborator( + this: IExecuteFunctions, + index: number, +): Promise { + const searchString = this.getNodeParameter('searchString', index) as string; + + const collaboratorsResult = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/related-users/', + ); + const collaborators = collaboratorsResult.user_list || []; + + const collaborator = collaborators.filter( + (col: ICollaborator) => + col.contact_email.includes(searchString) || col.name.includes(searchString), + ); + + return this.helpers.returnJsonArray(collaborator as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator/index.ts new file mode 100644 index 0000000000..ca007fb424 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/collaborator/index.ts @@ -0,0 +1,4 @@ +import { collaborator as execute } from './execute'; +import { baseCollaboratorDescription as description } from './description'; + +export { description, execute }; 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..9e83556515 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/index.ts @@ -0,0 +1,53 @@ +import * as snapshot from './snapshot'; +import * as metadata from './metadata'; +import * as apiCall from './apiCall'; +import * as collaborator from './collaborator'; + +import type { INodeProperties } from 'n8n-workflow'; + +export { snapshot, metadata, apiCall, 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: 'API Call', + value: 'apiCall', + description: 'Perform an authorized API call (Base Operation)', + action: 'Make an API Call', + }, + { + name: 'Collaborator', + value: 'collaborator', + description: 'Get this username from the email or name of a collaborator.', + action: 'Get username from email or name', + }, + ], + default: '', + }, + ...snapshot.description, + ...metadata.description, + ...apiCall.description, + ...collaborator.description, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata/description.ts new file mode 100644 index 0000000000..7ecc84ee01 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata/description.ts @@ -0,0 +1,3 @@ +import type { BaseProperties } from '../../Interfaces'; + +export const baseMetadataDescription: BaseProperties = []; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata/execute.ts new file mode 100644 index 0000000000..5ee8904e92 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata/execute.ts @@ -0,0 +1,15 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; + +export async function metadata( + this: IExecuteFunctions, + index: number, +): Promise { + const responseData = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata/', + ); + return this.helpers.returnJsonArray(responseData.metadata as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata/index.ts new file mode 100644 index 0000000000..5c1fc8a545 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/metadata/index.ts @@ -0,0 +1,4 @@ +import { metadata as execute } from './execute'; +import { baseMetadataDescription as description } from './description'; + +export { description, execute }; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot/description.ts new file mode 100644 index 0000000000..4a414cc8ed --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot/description.ts @@ -0,0 +1,3 @@ +import type { BaseProperties } from '../../Interfaces'; + +export const baseSnapshotDescription: BaseProperties = []; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot/execute.ts new file mode 100644 index 0000000000..9cea3c2374 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot/execute.ts @@ -0,0 +1,17 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; + +export async function snapshot( + this: IExecuteFunctions, + index: number, +): Promise { + const responseData = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/snapshot/', + { dtable_name: 'snapshot' }, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot/index.ts new file mode 100644 index 0000000000..085a4731a5 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/base/snapshot/index.ts @@ -0,0 +1,4 @@ +import { snapshot as execute } from './execute'; +import { baseSnapshotDescription as description } from './description'; + +export { description, execute }; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/add/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/add/description.ts new file mode 100644 index 0000000000..e82c1f6a46 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/add/description.ts @@ -0,0 +1,69 @@ +import type { LinkProperties } from '../../Interfaces'; + +export const linkAddDescription: LinkProperties = [ + { + displayName: 'Table Name (Source)', + name: 'tableName', + type: 'options', + placeholder: 'Name of table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNameAndId', + }, + displayOptions: { + show: { + resource: ['link'], + operation: ['add'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Link column', + name: 'linkColumn', + type: 'options', + displayOptions: { + show: { + resource: ['link'], + operation: ['add'], + }, + }, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getLinkColumns', + }, + required: true, + default: '', + description: 'Select the column to create a link.', + }, + { + displayName: 'Row ID from the source table', + name: 'linkColumnSourceId', + type: 'string', + displayOptions: { + show: { + resource: ['link'], + operation: ['add'], + }, + }, + required: true, + default: '', + description: 'Provide the row ID of table you selected.', + }, + { + displayName: 'Row ID from the target', + name: 'linkColumnTargetId', + type: 'string', + displayOptions: { + show: { + resource: ['link'], + operation: ['add'], + }, + }, + required: true, + default: '', + description: 'Provide the row ID of table you want to link.', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/add/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/add/execute.ts new file mode 100644 index 0000000000..34fe83a8f0 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/add/execute.ts @@ -0,0 +1,25 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; + +export async function add(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 responseData = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/links/', + { + link_id: linkColumn.split(':::')[1], + table_id: tableName.split(':::')[1], + table_row_id: linkColumnSourceId, + other_table_id: linkColumn.split(':::')[2], + other_table_row_id: linkColumnTargetId, + }, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/add/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/add/index.ts new file mode 100644 index 0000000000..bda9cb5f0e --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/add/index.ts @@ -0,0 +1,4 @@ +import { add as execute } from './execute'; +import { linkAddDescription as description } from './description'; + +export { description, execute }; 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..56d3c58257 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/index.ts @@ -0,0 +1,36 @@ +import * as add from './add'; +import * as remove from './remove'; +import type { INodeProperties } from 'n8n-workflow'; + +export { add, 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: 'Remove', + value: 'remove', + description: 'Remove a link between two rows from a link column', + action: 'Remove a row link', + }, + ], + default: 'add', + }, + ...add.description, + ...remove.description, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove/description.ts new file mode 100644 index 0000000000..a7f4fc35c0 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove/description.ts @@ -0,0 +1,69 @@ +import type { LinkProperties } from '../../Interfaces'; + +export const linkRemoveDescription: LinkProperties = [ + { + displayName: 'Table Name (Source)', + name: 'tableName', + type: 'options', + placeholder: 'Name of table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNameAndId', + }, + displayOptions: { + show: { + resource: ['link'], + operation: ['remove'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Link column', + name: 'linkColumn', + type: 'options', + displayOptions: { + show: { + resource: ['link'], + operation: ['remove'], + }, + }, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getLinkColumns', + }, + required: true, + default: '', + description: 'Select the column to create a link.', + }, + { + displayName: 'Row ID from the source table', + name: 'linkColumnSourceId', + type: 'string', + displayOptions: { + show: { + resource: ['link'], + operation: ['remove'], + }, + }, + required: true, + default: '', + description: 'Provide the row ID of table you selected.', + }, + { + displayName: 'Row ID from the target', + name: 'linkColumnTargetId', + type: 'string', + displayOptions: { + show: { + resource: ['link'], + operation: ['remove'], + }, + }, + required: true, + default: '', + description: 'Provide the row ID of table you want to link.', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove/execute.ts new file mode 100644 index 0000000000..0c9b7c9dfc --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove/execute.ts @@ -0,0 +1,28 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; + +export async function remove( + 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 responseData = await seaTableApiRequest.call( + this, + {}, + 'DELETE', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/links/', + { + link_id: linkColumn.split(':::')[1], + table_id: tableName.split(':::')[1], + table_row_id: linkColumnSourceId, + other_table_id: linkColumn.split(':::')[2], + other_table_row_id: linkColumnTargetId, + }, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove/index.ts new file mode 100644 index 0000000000..f22fce125a --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/link/remove/index.ts @@ -0,0 +1,4 @@ +import { remove as execute } from './execute'; +import { linkRemoveDescription as description } from './description'; + +export { description, execute }; 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..c9e7a58c45 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/router.ts @@ -0,0 +1,56 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; + +import * as row from './row'; +import * as base from './base'; +import * as link from './link'; +import * as asset from './asset'; + +import type { SeaTable } from './Interfaces'; + +export async function router(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const operationResult: INodeExecutionData[] = []; + let responseData: IDataObject | IDataObject[] = []; + + //console.log('ITEM LENGTH ' + items.length); + + for (let i = 0; i < items.length; i++) { + const resource = this.getNodeParameter('resource', i); + const operation = this.getNodeParameter('operation', i); + + //console.log(operation); + //console.log(resource); + + 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( + this.helpers.returnJsonArray(responseData), + { itemData: { item: i } }, + ); + operationResult.push(...executionData); + } catch (err) { + if (this.continueOnFail()) { + operationResult.push({ json: this.getInputData(i)[0].json, error: err }); + } else { + if (err.context) err.context.itemIndex = i; + throw err; + } + } + } + + return [operationResult]; +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/create/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/create/description.ts new file mode 100644 index 0000000000..895b3b9b8f --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/create/description.ts @@ -0,0 +1,121 @@ +import type { RowProperties } from '../../Interfaces'; + +export const rowCreateDescription: RowProperties = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['create'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Data to Send', + name: 'fieldsToSend', + type: 'options', + options: [ + { + name: 'Auto-Map Input Data to Columns', + value: 'autoMapInputData', + description: 'Use when node input properties match destination column names', + }, + { + name: 'Define Below for Each Column', + value: 'defineBelow', + description: 'Set the value for each destination column', + }, + ], + displayOptions: { + show: { + resource: ['row'], + operation: ['create'], + }, + }, + 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: ['create'], + fieldsToSend: ['autoMapInputData'], + }, + }, + default: '', + description: + 'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.', + placeholder: 'Enter properties...', + }, + { + displayName: 'Columns to Send', + name: 'columnsUi', + placeholder: 'Add Column', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Column to Send', + multipleValues: true, + }, + options: [ + { + displayName: 'Column', + name: 'columnValues', + values: [ + { + displayName: 'Column Name', + name: 'columnName', + type: 'options', + 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: ['create'], + fieldsToSend: ['defineBelow'], + }, + }, + default: {}, + description: 'Add destination column with its value', + }, + { + displayName: 'Hint: Link, files, images or digital signatures have to be added separately.', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + resource: ['row'], + operation: ['create'], + }, + }, + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/create/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/create/execute.ts new file mode 100644 index 0000000000..7ffef50455 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/create/execute.ts @@ -0,0 +1,62 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { + seaTableApiRequest, + getTableColumns, + split, + rowExport, + updateAble, + splitStringColumnsToArrays, +} from '../../../GenericFunctions'; +import type { IRowObject } from '../../Interfaces'; +import type { TColumnValue, TColumnsUiValues } from '../../../types'; + +export async function create( + 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 body = { + table_name: tableName, + row: {}, + } 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); + + body.row = rowInput; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/', + body, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/create/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/create/index.ts new file mode 100644 index 0000000000..220e8afb00 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/create/index.ts @@ -0,0 +1,4 @@ +import { create as execute } from './execute'; +import { rowCreateDescription as description } from './description'; + +export { description, execute }; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/get/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/get/description.ts new file mode 100644 index 0000000000..301653d1b1 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/get/description.ts @@ -0,0 +1,54 @@ +import type { RowProperties } from '../../Interfaces'; + +export const rowGetDescription: RowProperties = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['get'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Row ID', + name: 'rowId', + type: 'options', + required: true, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getRowIds', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['get'], + }, + }, + default: '', + }, + { + displayName: 'Simplify output', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + resource: ['row'], + operation: ['get'], + }, + }, + default: true, + description: + 'Simplified returns only the columns of your base. Non-simplified will return additional columns like _ctime (=creation time), _mtime (=modification time) etc.', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/get/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/get/execute.ts new file mode 100644 index 0000000000..de548a1bcd --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/get/execute.ts @@ -0,0 +1,42 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import type { IRow, IRowResponse, IDtableMetadataColumn } from './../../Interfaces'; +import { + seaTableApiRequest, + enrichColumns, + simplify_new, + getBaseCollaborators, +} from '../../../GenericFunctions'; + +export async function get(this: IExecuteFunctions, index: number): Promise { + // get parameters + const tableName = this.getNodeParameter('tableName', index) as string; + const rowId = this.getNodeParameter('rowId', index) as string; + const simple = this.getNodeParameter('simple', index) as boolean; + + // get collaborators + const collaborators = await getBaseCollaborators.call(this); + + // get rows + let sqlResult = (await seaTableApiRequest.call( + this, + {}, + 'POST', + '/dtable-db/api/v1/query/{{dtable_uuid}}/', + { + sql: `SELECT * FROM \`${tableName}\` WHERE _id = '${rowId}'`, + convert_keys: true, + }, + )) as IRowResponse; + let metadata = sqlResult.metadata as IDtableMetadataColumn[]; + let rows = sqlResult.results as IRow[]; + + // hide columns like button + rows.map((row) => enrichColumns(row, metadata, collaborators)); + + // 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/get/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/get/index.ts new file mode 100644 index 0000000000..7f9c1e982f --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/get/index.ts @@ -0,0 +1,4 @@ +import { get as execute } from './execute'; +import { rowGetDescription as description } from './description'; + +export { description, execute }; 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..b6453601f9 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/index.ts @@ -0,0 +1,76 @@ +import * as create from './create'; +import * as get from './get'; +import * as search from './search'; +import * as update from './update'; +import * as remove from './remove'; +import * as lock from './lock'; +import * as unlock from './unlock'; +import type { INodeProperties } from 'n8n-workflow'; + +export { create, get, search, update, remove, lock, unlock }; + +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: 'Get', + value: 'get', + description: 'Get the content of a row', + action: 'Get a row', + }, + { + name: 'Search', + value: 'search', + description: 'Search one or multiple rows', + action: 'Search a row by keyword', + }, + { + name: 'Update', + value: 'update', + description: 'Update the content of a row', + action: 'Update a row', + }, + { + name: 'Delete', + value: 'remove', + description: 'Delete a row', + action: 'Delete a row', + }, + { + name: 'Lock', + value: 'lock', + description: 'Lock a row to prevent further changes.', + action: 'Add a row lock', + }, + { + name: 'Unlock', + value: 'unlock', + description: 'Remove the lock from a row', + action: 'Remove a row lock', + }, + ], + default: 'create', + }, + ...create.description, + ...get.description, + ...search.description, + ...update.description, + ...remove.description, + ...lock.description, + ...unlock.description, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock/description.ts new file mode 100644 index 0000000000..e1ed6e1194 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock/description.ts @@ -0,0 +1,40 @@ +import type { RowProperties } from '../../Interfaces'; + +export const rowLockDescription: RowProperties = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['lock'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Row ID', + name: 'rowId', + type: 'options', + required: true, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getRowIds', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['lock'], + }, + }, + default: '', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock/execute.ts new file mode 100644 index 0000000000..0a6b06fe7f --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock/execute.ts @@ -0,0 +1,20 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; + +export async function lock(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', + '/dtable-server/api/v1/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/lock/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock/index.ts new file mode 100644 index 0000000000..96cea685b2 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/lock/index.ts @@ -0,0 +1,4 @@ +import { lock as execute } from './execute'; +import { rowLockDescription as description } from './description'; + +export { description, execute }; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove/description.ts new file mode 100644 index 0000000000..fca64f49dc --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove/description.ts @@ -0,0 +1,40 @@ +import type { RowProperties } from '../../Interfaces'; + +export const rowRemoveDescription: RowProperties = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['remove'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Row ID', + name: 'rowId', + type: 'options', + required: true, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getRowIds', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['remove'], + }, + }, + default: '', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove/execute.ts new file mode 100644 index 0000000000..62e5d0b684 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove/execute.ts @@ -0,0 +1,25 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; + +export async function remove( + 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_id: rowId, + }; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'DELETE', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/', + requestBody, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove/index.ts new file mode 100644 index 0000000000..38e5a10043 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/remove/index.ts @@ -0,0 +1,4 @@ +import { remove as execute } from './execute'; +import { rowRemoveDescription as description } from './description'; + +export { description, execute }; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/search/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/search/description.ts new file mode 100644 index 0000000000..ce94fa6ca8 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/search/description.ts @@ -0,0 +1,83 @@ +import type { RowProperties } from '../../Interfaces'; + +export const rowSearchDescription: RowProperties = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['search'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Column', + name: 'searchColumn', + type: 'options', + displayOptions: { + show: { + resource: ['row'], + operation: ['search'], + }, + }, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getSearchableColumns', + }, + required: true, + default: '', + description: 'Select the column to be searched. Not all column types are supported for search.', + }, + { + displayName: 'Search term', + name: 'searchTerm', + type: 'string', + displayOptions: { + show: { + resource: ['row'], + operation: ['search'], + }, + }, + required: true, + default: '', + description: 'What to look for?', + }, + { + displayName: 'Activate wildcard search', + name: 'wildcard', + type: 'boolean', + displayOptions: { + show: { + resource: ['row'], + operation: ['search'], + }, + }, + default: false, + description: + 'FALSE: The search only results perfect matches. TRUE: Finds a row even if the search value is part of a string.', + }, + { + displayName: 'Simplify output', + name: 'simple', + type: 'boolean', + default: true, + displayOptions: { + show: { + resource: ['row'], + operation: ['search'], + }, + }, + description: + 'Simplified returns only the columns of your base. Non-simplified will return additional columns like _ctime (=creation time), _mtime (=modification time) etc.', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/search/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/search/execute.ts new file mode 100644 index 0000000000..816d9c5107 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/search/execute.ts @@ -0,0 +1,67 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { + seaTableApiRequest, + enrichColumns, + simplify_new, + getBaseCollaborators, +} from '../../../GenericFunctions'; +import { IDtableMetadataColumn, IRow, IRowResponse } from '../../Interfaces'; + +export async function search( + 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 any; // string or integer + const wildcard = this.getNodeParameter('wildcard', index) as boolean; + const simple = this.getNodeParameter('simple', index) as boolean; + + // get collaborators + const collaborators = await getBaseCollaborators.call(this); + + //let metadata: IDtableMetadataColumn[] = []; + //let rows: IRow[]; + //let sqlResult: IRowResponse; + + // get the collaborators (avoid executing this multiple times !!!!) + /*let collaboratorsResult: ICollaboratorsResult = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/related-users/', + ); + let collaborators: ICollaborator[] = collaboratorsResult.user_list || []; + */ + + // this is the base query. The WHERE has to be finalized... + let sqlQuery = `SELECT * FROM \`${tableName}\` WHERE \`${searchColumn}\``; + + if (wildcard && isNaN(searchTerm)) sqlQuery = sqlQuery + ' LIKE "%' + searchTerm + '%"'; + else if (!wildcard && isNaN(searchTerm)) sqlQuery = sqlQuery + ' = "' + searchTerm + '"'; + else if (wildcard && !isNaN(searchTerm)) sqlQuery = sqlQuery + ' LIKE %' + searchTerm + '%'; + else if (!wildcard && !isNaN(searchTerm)) sqlQuery = sqlQuery + ' = ' + searchTerm; + + const sqlResult = (await seaTableApiRequest.call( + this, + {}, + 'POST', + '/dtable-db/api/v1/query/{{dtable_uuid}}/', + { + sql: sqlQuery, + convert_keys: true, + }, + )) as IRowResponse; + const metadata = sqlResult.metadata as IDtableMetadataColumn[]; + let rows = sqlResult.results as IRow[]; + + // hide columns like button + rows.map((row) => enrichColumns(row, metadata, collaborators)); + + // remove columns starting with _; + 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/search/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/search/index.ts new file mode 100644 index 0000000000..faaac81429 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/search/index.ts @@ -0,0 +1,4 @@ +import { search as execute } from './execute'; +import { rowSearchDescription as description } from './description'; + +export { description, execute }; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock/description.ts new file mode 100644 index 0000000000..dcea346302 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock/description.ts @@ -0,0 +1,40 @@ +import type { RowProperties } from '../../Interfaces'; + +export const rowUnlockDescription: RowProperties = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['unlock'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Row ID', + name: 'rowId', + type: 'options', + required: true, + typeOptions: { + loadOptionsDependsOn: ['tableName'], + loadOptionsMethod: 'getRowIds', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['unlock'], + }, + }, + default: '', + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock/execute.ts new file mode 100644 index 0000000000..3db5e1c50c --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock/execute.ts @@ -0,0 +1,23 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { seaTableApiRequest } from '../../../GenericFunctions'; + +export async function unlock( + 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', + '/dtable-server/api/v1/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/unlock/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock/index.ts new file mode 100644 index 0000000000..d904cf0bca --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/unlock/index.ts @@ -0,0 +1,4 @@ +import { unlock as execute } from './execute'; +import { rowUnlockDescription as description } from './description'; + +export { description, execute }; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/update/description.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/update/description.ts new file mode 100644 index 0000000000..473323d4b7 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/update/description.ts @@ -0,0 +1,137 @@ +import type { RowProperties } from '../../Interfaces'; + +export const rowUpdateDescription: RowProperties = [ + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + placeholder: 'Select a table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['update'], + }, + }, + default: '', + description: + 'The name of SeaTable table to access. Choose from the list, or specify a name using an expression.', + }, + { + displayName: 'Row ID', + name: 'rowId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getRowIds', + }, + displayOptions: { + show: { + resource: ['row'], + operation: ['update'], + }, + }, + default: '', + }, + { + displayName: 'Data to Send', + name: 'fieldsToSend', + type: 'options', + options: [ + { + name: 'Auto-Map Input Data to Columns', + value: 'autoMapInputData', + description: 'Use when node input properties match destination column names', + }, + { + name: 'Define Below for Each Column', + value: 'defineBelow', + description: 'Set the value for each destination column', + }, + ], + displayOptions: { + show: { + resource: ['row'], + operation: ['update'], + }, + }, + default: 'defineBelow', + description: 'Whether to insert the input data this node receives in the new row', + }, + { + displayName: 'Inputs to Ignore', + name: 'inputsToIgnore', + type: 'string', + displayOptions: { + show: { + 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', + 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', + }, + { + displayName: 'Hint: Link, files, images or digital signatures have to be added separately.', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + resource: ['row'], + operation: ['update'], + }, + }, + }, +]; diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/update/execute.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/update/execute.ts new file mode 100644 index 0000000000..37a7435789 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/update/execute.ts @@ -0,0 +1,63 @@ +import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow'; +import { + seaTableApiRequest, + getTableColumns, + split, + rowExport, + updateAble, + splitStringColumnsToArrays, +} from '../../../GenericFunctions'; +import type { IRowObject } from '../../Interfaces'; +import type { TColumnsUiValues, TColumnValue } from '../../../types'; + +export async function update( + 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; + + const body = { + table_name: tableName, + row_id: rowId, + } 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); + + body.row = rowInput; + + const responseData = await seaTableApiRequest.call( + this, + {}, + 'PUT', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/', + body, + ); + + return this.helpers.returnJsonArray(responseData as IDataObject[]); +} diff --git a/packages/nodes-base/nodes/SeaTable/v2/actions/row/update/index.ts b/packages/nodes-base/nodes/SeaTable/v2/actions/row/update/index.ts new file mode 100644 index 0000000000..512ce6a286 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/row/update/index.ts @@ -0,0 +1,4 @@ +import { update as execute } from './execute'; +import { rowUpdateDescription as description } from './description'; + +export { description, execute }; 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..c9b37d41da --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/actions/versionDescription.ts @@ -0,0 +1,57 @@ +import type { INodeTypeDescription } from 'n8n-workflow'; +import * as row from './row'; +import * as base from './base'; +import * as link from './link'; +import * as asset from './asset'; + +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: ['main'], + outputs: ['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..9f6f0518f0 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/methods/loadOptions.ts @@ -0,0 +1,227 @@ +import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; +import { getTableColumns, seaTableApiRequest, updateAble } from '../GenericFunctions'; +import type { IRow } from '../actions/Interfaces'; + +export async function getTableNames(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + // this.getCurrentNodeParameter('viewName'); // das kommt vom trigger. Brauche ich das??? + 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; +} + +export async function getTableNameAndId( + this: ILoadOptionsFunctions, +): Promise { + 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 + ':::' + 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', + '/dtable-server/api/v1/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[] = []; + let tableName = this.getCurrentNodeParameter('tableName') as string; + tableName = tableName.split(':::')[0]; + //const tableId = (tableName.split(':::')[1] ? tableName.split(':::')[1] : ""); + if (tableName) { + const columns = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/columns', + {}, + { table_name: tableName }, + ); + for (const col of columns.columns) { + if (col.type === 'link') { + returnData.push({ + name: col.name, + value: col.name + ':::' + col.data.link_id + ':::' + col.data.other_table_id, + }); + } + } + } + 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', + '/dtable-server/api/v1/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', + '/dtable-server/api/v1/dtables/{{dtable_uuid}}/columns', + {}, + { table_name: tableName }, + ); + for (const col of columns.columns) { + if (col.type === 'digital-sign') { + // file+image are difficult: every time the row changes, all files trigger. + 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); + + // remove columns that could not be filled + 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 tableName = this.getNodeParameter('tableName') as string; + const returnData: INodePropertyOptions[] = []; + + if (tableName) { + const sqlResult = await seaTableApiRequest.call( + this, + {}, + 'POST', + '/dtable-db/api/v1/query/{{dtable_uuid}}/', + { + sql: `SELECT * FROM \`${tableName}\` LIMIT 1000`, + convert_keys: false, + }, + ); + let rows = sqlResult.results as IRow[]; + + for (const row of rows) { + returnData.push({ + name: row['0000'] + ' (' + 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) { + // only execute if table is selected, to avoid unnecessary API requests + const { views } = await seaTableApiRequest.call( + this, + {}, + 'GET', + '/dtable-server/api/v1/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..db0078d9c5 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/v2/types.ts @@ -0,0 +1,97 @@ +// ---------------------------------- +// SeaTable +// ---------------------------------- + +export type TSeaTableServerVersion = '2.0.6'; +export type TSeaTableServerEdition = 'enterprise edition'; + +// ---------------------------------- +// dtable +// ---------------------------------- + +import type { + IDtableMetadataColumn, + IDtableMetadataTable, + TDtableViewColumn, +} from './actions/Interfaces'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; + +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; +}>;