feat(SeaTable Node): Update node with new options (#11431)

This commit is contained in:
Jon
2025-03-17 14:40:58 +00:00
committed by GitHub
parent 97339eaf73
commit d0fdb11499
46 changed files with 4275 additions and 615 deletions

View File

@@ -1,5 +1,10 @@
import moment from 'moment-timezone';
import type { ICredentialType, INodeProperties, INodePropertyOptions } from 'n8n-workflow';
import type {
ICredentialTestRequest,
ICredentialType,
INodeProperties,
INodePropertyOptions,
} from 'n8n-workflow';
// Get options for timezones
const timezones: INodePropertyOptions[] = moment.tz
@@ -40,7 +45,7 @@ export class SeaTableApi implements ICredentialType {
name: 'domain',
type: 'string',
default: '',
placeholder: 'https://www.mydomain.com',
placeholder: 'https://seatable.example.com',
displayOptions: {
show: {
environment: ['selfHosted'],
@@ -51,6 +56,8 @@ export class SeaTableApi implements ICredentialType {
displayName: 'API Token (of a Base)',
name: 'token',
type: 'string',
description:
'The API-Token of the SeaTable base you would like to use with n8n. n8n can only connect to one base at a time.',
typeOptions: { password: true },
default: '',
},
@@ -63,4 +70,14 @@ export class SeaTableApi implements ICredentialType {
options: [...timezones],
},
];
test: ICredentialTestRequest = {
request: {
baseURL: '={{$credentials?.domain || "https://cloud.seatable.io" }}',
url: '/api/v2.1/dtable/app-access-token/',
headers: {
Authorization: '={{"Token " + $credentials.token}}',
},
},
};
}

View File

@@ -1,449 +1,27 @@
import type {
IExecuteFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import {
getTableColumns,
getTableViews,
rowExport,
rowFormatColumns,
rowMapKeyToName,
seaTableApiRequest,
setableApiRequestAllItems,
split,
updateAble,
} from './GenericFunctions';
import type { ICtx, IRow, IRowObject } from './Interfaces';
import { rowFields, rowOperations } from './RowDescription';
import type { TColumnsUiValues, TColumnValue } from './types';
import { SeaTableV1 } from './v1/SeaTableV1.node';
import { SeaTableV2 } from './v2/SeaTableV2.node';
export class SeaTable implements INodeType {
description: INodeTypeDescription = {
displayName: 'SeaTable',
name: 'seaTable',
icon: 'file:seaTable.svg',
group: ['input'],
version: 1,
subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}',
description: 'Consume the SeaTable API',
defaults: {
name: 'SeaTable',
},
usableAsTool: true,
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [
{
name: 'seaTableApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Row',
value: 'row',
},
],
default: 'row',
},
...rowOperations,
...rowFields,
],
};
export class SeaTable extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'SeaTable',
name: 'seaTable',
icon: 'file:seaTable.svg',
group: ['output'],
subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}',
description: 'Read, update, write and delete data from SeaTable',
defaultVersion: 2,
usableAsTool: true,
};
methods = {
loadOptions: {
async getTableNames(this: ILoadOptionsFunctions) {
const returnData: INodePropertyOptions[] = [];
const {
metadata: { tables },
} = await seaTableApiRequest.call(
this,
{},
'GET',
'/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata',
);
for (const table of tables) {
returnData.push({
name: table.name,
value: table.name,
});
}
return returnData;
},
async getTableIds(this: ILoadOptionsFunctions) {
const returnData: INodePropertyOptions[] = [];
const {
metadata: { tables },
} = await seaTableApiRequest.call(
this,
{},
'GET',
'/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata',
);
for (const table of tables) {
returnData.push({
name: table.name,
value: table._id,
});
}
return returnData;
},
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new SeaTableV1(baseDescription),
2: new SeaTableV2(baseDescription),
};
async getTableUpdateAbleColumns(this: ILoadOptionsFunctions) {
const tableName = this.getNodeParameter('tableName') as string;
const columns = await getTableColumns.call(this, tableName);
return columns
.filter((column) => column.editable)
.map((column) => ({ name: column.name, value: column.name }));
},
async getAllSortableColumns(this: ILoadOptionsFunctions) {
const tableName = this.getNodeParameter('tableName') as string;
const columns = await getTableColumns.call(this, tableName);
return columns
.filter(
(column) =>
!['file', 'image', 'url', 'collaborator', 'long-text'].includes(column.type),
)
.map((column) => ({ name: column.name, value: column.name }));
},
async getViews(this: ILoadOptionsFunctions) {
const tableName = this.getNodeParameter('tableName') as string;
const views = await getTableViews.call(this, tableName);
return views.map((view) => ({ name: view.name, value: view.name }));
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
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);
}
}

View File

@@ -1,157 +1,25 @@
import moment from 'moment-timezone';
import {
type IPollFunctions,
type ILoadOptionsFunctions,
type INodeExecutionData,
type INodePropertyOptions,
type INodeType,
type INodeTypeDescription,
NodeConnectionType,
} from 'n8n-workflow';
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { getColumns, rowFormatColumns, seaTableApiRequest, simplify } from './GenericFunctions';
import type { ICtx, IRow, IRowResponse } from './Interfaces';
import { SeaTableTriggerV1 } from './v1/SeaTableTriggerV1.node';
import { SeaTableTriggerV2 } from './v2/SeaTableTriggerV2.node';
export class SeaTableTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'SeaTable Trigger',
name: 'seaTableTrigger',
icon: 'file:seaTable.svg',
group: ['trigger'],
version: 1,
description: 'Starts the workflow when SeaTable events occur',
subtitle: '={{$parameter["event"]}}',
defaults: {
name: 'SeaTable Trigger',
},
credentials: [
{
name: 'seaTableApi',
required: true,
},
],
polling: true,
inputs: [],
outputs: [NodeConnectionType.Main],
properties: [
{
displayName: 'Table Name or ID',
name: 'tableName',
type: 'options',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableNames',
},
default: '',
description:
'The name of SeaTable table to access. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Event',
name: 'event',
type: 'options',
options: [
{
name: 'Row Created',
value: 'rowCreated',
description: 'Trigger on newly created rows',
},
// {
// name: 'Row Modified',
// value: 'rowModified',
// description: 'Trigger has recently modified rows',
// },
],
default: 'rowCreated',
},
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
default: true,
description:
'Whether to return a simplified version of the response instead of the raw data',
},
],
};
export class SeaTableTrigger extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'SeaTable Trigger',
name: 'seaTableTrigger',
icon: 'file:seaTable.svg',
group: ['trigger'],
defaultVersion: 2,
description: 'Starts the workflow when SeaTable events occur',
};
methods = {
loadOptions: {
async getTableNames(this: ILoadOptionsFunctions) {
const returnData: INodePropertyOptions[] = [];
const {
metadata: { tables },
} = await seaTableApiRequest.call(
this,
{},
'GET',
'/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata',
);
for (const table of tables) {
returnData.push({
name: table.name,
value: table.name,
});
}
return returnData;
},
},
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new SeaTableTriggerV1(baseDescription),
2: new SeaTableTriggerV2(baseDescription),
};
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
const webhookData = this.getWorkflowStaticData('node');
const tableName = this.getNodeParameter('tableName') as string;
const simple = this.getNodeParameter('simple') as boolean;
const event = this.getNodeParameter('event') as string;
const ctx: ICtx = {};
const credentials = await this.getCredentials('seaTableApi');
const timezone = (credentials.timezone as string) || 'Europe/Berlin';
const now = moment().utc().format();
const startDate = (webhookData.lastTimeChecked as string) || now;
const endDate = now;
webhookData.lastTimeChecked = endDate;
let rows;
const filterField = event === 'rowCreated' ? '_ctime' : '_mtime';
const endpoint = '/dtable-db/api/v1/query/{{dtable_uuid}}/';
if (this.getMode() === 'manual') {
rows = (await seaTableApiRequest.call(this, ctx, 'POST', endpoint, {
sql: `SELECT * FROM ${tableName} LIMIT 1`,
})) as IRowResponse;
} else {
rows = (await seaTableApiRequest.call(this, ctx, 'POST', endpoint, {
sql: `SELECT * FROM ${tableName}
WHERE ${filterField} BETWEEN "${moment(startDate).tz(timezone).format('YYYY-MM-D HH:mm:ss')}"
AND "${moment(endDate).tz(timezone).format('YYYY-MM-D HH:mm:ss')}"`,
})) as IRowResponse;
}
let response;
if (rows.metadata && rows.results) {
const columns = getColumns(rows);
if (simple) {
response = simplify(rows, columns);
} else {
response = rows.results;
}
const allColumns = rows.metadata.map((meta) => meta.name);
response = response
//@ts-ignore
.map((row: IRow) => rowFormatColumns(row, allColumns))
.map((row: IRow) => ({ json: row }));
}
if (Array.isArray(response) && response.length) {
return [response];
}
return null;
super(nodeVersions, baseDescription);
}
}

View File

@@ -0,0 +1,317 @@
import type {
ICollaborator,
IDtableMetadataColumn,
IRow,
IRowObject,
IColumnDigitalSignature,
} from '../../v2/actions/Interfaces';
import {
enrichColumns,
rowExport,
simplify_new,
splitStringColumnsToArrays,
} from '../../v2/GenericFunctions';
import type { TDtableMetadataColumns } from '../../v2/types';
describe('Seatable > v2 > GenericFunctions', () => {
describe('rowExport', () => {
const mockColumns: TDtableMetadataColumns = [
{ key: 'a', name: 'id', type: 'text' },
{ key: 'b', name: 'name', type: 'text' },
{ key: 'c', name: 'age', type: 'number' },
];
it('should export only allowed columns from row', () => {
const row: IRowObject = {
id: '1',
name: 'John',
age: 30,
extraField: 'should not be included',
};
const expected: IRowObject = {
id: '1',
name: 'John',
age: 30,
};
expect(rowExport(row, mockColumns)).toEqual(expected);
});
it('should handle empty row', () => {
const row: IRowObject = {};
expect(rowExport(row, mockColumns)).toEqual({});
});
it('should handle row with missing fields', () => {
const row: IRowObject = {
id: '1',
// name is missing
age: 30,
};
const expected: IRowObject = {
id: '1',
age: 30,
};
expect(rowExport(row, mockColumns)).toEqual(expected);
});
});
describe('splitStringColumnsToArrays', () => {
it('should convert collaborator strings to arrays', () => {
const columns: TDtableMetadataColumns = [
{ key: 'a', name: 'collaborators', type: 'collaborator' },
];
const row: IRowObject = {
collaborators: 'john@example.com, jane@example.com',
};
const result = splitStringColumnsToArrays(row, columns);
expect(result.collaborators).toEqual(['john@example.com', 'jane@example.com']);
});
it('should convert multiple-select strings to arrays', () => {
const columns: TDtableMetadataColumns = [{ key: 'a', name: 'tags', type: 'multiple-select' }];
const row: IRowObject = {
tags: 'urgent, important',
};
const result = splitStringColumnsToArrays(row, columns);
expect(result.tags).toEqual(['urgent', 'important']);
});
it('should convert number strings to numbers', () => {
const columns: TDtableMetadataColumns = [{ key: 'a', name: 'amount', type: 'number' }];
const row: IRowObject = {
amount: '123.45',
};
const result = splitStringColumnsToArrays(row, columns);
expect(result.amount).toBe(123.45);
});
it('should convert rate and duration strings to integers', () => {
const columns: TDtableMetadataColumns = [
{ key: 'a', name: 'rating', type: 'rate' },
{ key: 'b', name: 'duration', type: 'duration' },
];
const row: IRowObject = {
rating: '4',
duration: '60',
};
const result = splitStringColumnsToArrays(row, columns);
expect(result.rating).toBe(4);
expect(result.duration).toBe(60);
});
it('should convert checkbox strings to booleans', () => {
const columns: TDtableMetadataColumns = [{ key: 'a', name: 'isActive', type: 'checkbox' }];
const testCases = [
{ input: 'true', expected: true },
{ input: 'on', expected: true },
{ input: '1', expected: true },
{ input: 'false', expected: false },
{ input: 'off', expected: false },
{ input: '0', expected: false },
];
testCases.forEach(({ input, expected }) => {
const row: IRowObject = { isActive: input };
const result = splitStringColumnsToArrays(row, columns);
expect(result.isActive).toBe(expected);
});
});
it('should handle multiple column types in one row', () => {
const columns: TDtableMetadataColumns = [
{ key: 'a', name: 'tags', type: 'multiple-select' },
{ key: 'b', name: 'amount', type: 'number' },
{ key: 'c', name: 'isActive', type: 'checkbox' },
];
const row: IRowObject = {
tags: 'tag1, tag2',
amount: '123.45',
isActive: 'true',
};
const result = splitStringColumnsToArrays(row, columns);
expect(result).toEqual({
tags: ['tag1', 'tag2'],
amount: 123.45,
isActive: true,
});
});
it('should handle empty/invalid inputs', () => {
const columns: TDtableMetadataColumns = [
{ key: 'a', name: 'empty', type: 'multiple-select' },
{ key: 'b', name: 'invalid', type: 'number' },
];
const row: IRowObject = {
empty: '',
invalid: 'not-a-number',
};
const result = splitStringColumnsToArrays(row, columns);
expect(result.empty).toEqual(['']);
expect(result.invalid).toBeNaN();
});
});
describe('enrichColumns', () => {
const baseRow = {
_id: '1234',
_ctime: '2024-01-01T00:00:00Z',
_mtime: '2024-01-01T00:00:00Z',
};
const mockCollaborators: ICollaborator[] = [
{
name: 'John Doe',
email: 'john@example.com',
contact_email: 'john@example.com',
},
{
name: 'Jane Smith',
email: 'jane@example.com',
contact_email: 'jane@example.com',
},
];
const mockMetadata: IDtableMetadataColumn[] = [
{ name: 'assignee', type: 'collaborator', key: 'assignee' },
{ name: 'creator', type: 'creator', key: '_creator' },
{ name: 'lastModifier', type: 'last-modifier', key: '_last_modifier' },
{ name: 'images', type: 'image', key: 'images' },
{ name: 'files', type: 'file', key: 'files' },
{ name: 'signature', type: 'digital-sign', key: 'signature' },
{ name: 'action', type: 'button', key: 'action' },
];
it('should preserve base IRow properties', () => {
const row: IRow = {
...baseRow,
assignee: ['john@example.com'],
};
const result = enrichColumns(row, mockMetadata, mockCollaborators);
expect(result._id).toBe(baseRow._id);
expect(result._ctime).toBe(baseRow._ctime);
expect(result._mtime).toBe(baseRow._mtime);
});
it('should enrich collaborator columns', () => {
const row: IRow = {
...baseRow,
assignee: ['john@example.com'],
};
const result = enrichColumns(row, mockMetadata, mockCollaborators);
expect(result.assignee).toEqual([
{
email: 'john@example.com',
contact_email: 'john@example.com',
name: 'John Doe',
},
]);
});
it('should enrich creator and last-modifier columns', () => {
const row: IRow = {
...baseRow,
creator: 'john@example.com',
lastModifier: 'jane@example.com',
};
const result = enrichColumns(row, mockMetadata, mockCollaborators);
expect(result.creator).toEqual({
email: 'john@example.com',
contact_email: 'john@example.com',
name: 'John Doe',
});
});
it('should enrich image columns', () => {
const row: IRow = {
...baseRow,
images: ['https://example.com/image.jpg'],
};
const result = enrichColumns(row, mockMetadata, mockCollaborators);
expect(result.images).toEqual([
{
name: 'image.jpg',
size: 0,
type: 'image',
url: 'https://example.com/image.jpg',
path: 'https://example.com/image.jpg',
},
]);
});
it('should handle empty/missing data gracefully', () => {
const row: IRow = {
...baseRow,
assignee: [],
images: [],
files: [],
signature: {} as IColumnDigitalSignature,
};
const result = enrichColumns(row, mockMetadata, mockCollaborators);
expect(result.assignee).toEqual([]);
expect(result.images).toEqual([]);
expect(result.files).toEqual([]);
expect(result.signature).toEqual({});
});
});
describe('simplify_new', () => {
it('should remove keys starting with underscore', () => {
const input: IRow = {
_id: '123',
_ctime: '2024-01-01',
_mtime: '2024-01-01',
name: 'Test',
value: 42,
};
const expected = {
name: 'Test',
value: 42,
};
expect(simplify_new(input)).toEqual(expected);
});
it('should handle empty object', () => {
const input: IRow = {
_id: '123',
_ctime: '2024-01-01',
_mtime: '2024-01-01',
};
expect(simplify_new(input)).toEqual({});
});
it('should preserve non-underscore keys', () => {
const input: IRow = {
_id: '123',
_ctime: '2024-01-01',
_mtime: '2024-01-01',
normal_key: 'value',
dash_key: 'value',
};
const expected = {
normal_key: 'value',
dash_key: 'value',
};
expect(simplify_new(input)).toEqual(expected);
});
});
});

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><path fill="url(#a)" d="M16.787 43.213 28.574 55l16.943-16.942a7.87 7.87 0 0 0 0-11.132l-6.22-6.221-.112-.111-18.611 18.57.13.131z"/><path fill="#ff8000" d="m20.704 39.295 22.51-22.507L31.425 5 14.483 21.942a7.87 7.87 0 0 0 0 11.133z"/><defs><linearGradient id="a" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(-109.048 29.213 6.813)scale(10.08407)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8000"/><stop offset="1" stop-color="#ec2837"/></linearGradient></defs></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><path d="M16.787 43.213L28.574 55l16.943-16.942a7.872 7.872 0 000-11.132l-6.22-6.221-.112-.111-18.611 18.57.13.131z" fill="url(#g1)"/><path d="M20.704 39.295l22.51-22.507L31.425 5 14.483 21.942a7.872 7.872 0 000 11.133z" fill="#ff8000"/><defs id="d1"><linearGradient id="g1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="scale(-10.08407) rotate(70.952 .948 -4.065)"><stop offset="0" id="stop905" stop-color="#ff8000" stop-opacity="1"/><stop offset="1" id="stop907" stop-color="#ec2837" stop-opacity="1"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 560 B

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -3,9 +3,9 @@ import type {
IExecuteFunctions,
ILoadOptionsFunctions,
IPollFunctions,
JsonObject,
IHttpRequestMethods,
IRequestOptions,
IHttpRequestMethods,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
@@ -64,8 +64,8 @@ function endpointCtxExpr(ctx: ICtx, endpoint: string): string {
endpointVariables.dtable_uuid = ctx?.base?.dtable_uuid;
return endpoint.replace(
/({{ *(access_token|dtable_uuid|server) *}})/g,
(match: string, _: string, name: TEndpointVariableName) => {
/{{ *(access_token|dtable_uuid|server) *}}/g,
(match: string, name: TEndpointVariableName) => {
return endpointVariables[name] || match;
},
);
@@ -76,7 +76,6 @@ export async function seaTableApiRequest(
ctx: ICtx,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
qs: IDataObject = {},
url: string | undefined = undefined,

View File

@@ -49,10 +49,11 @@ export const rowFields: INodeProperties[] = [
// ----------------------------------
{
displayName: 'Table Name or ID',
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Table Name',
name: 'tableName',
type: 'options',
placeholder: 'Name of table',
placeholder: 'Name of the table',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableNames',
@@ -63,14 +64,16 @@ export const rowFields: INodeProperties[] = [
},
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'The name of SeaTable table to access. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
'The name of SeaTable table to access. Choose from the list, or specify the name using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
},
{
displayName: 'Table Name or ID',
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Table ID',
name: 'tableId',
type: 'options',
placeholder: 'Name of table',
placeholder: 'ID of the table',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableIds',
@@ -81,6 +84,7 @@ export const rowFields: INodeProperties[] = [
},
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'The name of SeaTable table to access. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
@@ -157,11 +161,13 @@ export const rowFields: INodeProperties[] = [
name: 'columnValues',
values: [
{
displayName: 'Column Name or ID',
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Column Name',
name: 'columnName',
type: 'options',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
'Choose from the list, or specify the name using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
typeOptions: {
loadOptionsDependsOn: ['table'],
loadOptionsMethod: 'getTableUpdateAbleColumns',
@@ -243,7 +249,6 @@ export const rowFields: INodeProperties[] = [
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
description: 'Max number of results to return',
@@ -261,11 +266,13 @@ export const rowFields: INodeProperties[] = [
},
options: [
{
displayName: 'View Name or ID',
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'View Name',
name: 'view_name',
type: 'options',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
'Choose from the list, or specify an View Name using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getViews',
},
@@ -291,7 +298,7 @@ export const rowFields: INodeProperties[] = [
type: 'boolean',
default: false,
description:
'Whether the link column in the returned row is the ID of the linked row or the name of the linked row',
'Whether the ID of the linked row is returned in the link column (true). Otherwise, it return the name of the linked row (false).',
},
{
displayName: 'Direction',
@@ -312,15 +319,16 @@ export const rowFields: INodeProperties[] = [
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Order By',
displayName: 'Order By Column',
name: 'order_by',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getAllSortableColumns',
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'A column\'s name or ID, use this column to sort the rows. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
'Choose from the list, or specify a Column using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
},
],
},

View File

@@ -0,0 +1,41 @@
import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow';
import { rowFields, rowOperations } from './RowDescription';
export const versionDescription: INodeTypeDescription = {
displayName: 'SeaTable',
name: 'seaTable',
icon: 'file:seaTable.svg',
group: ['input'],
version: 1,
subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}',
description: 'Consume the SeaTable API',
defaults: {
name: 'SeaTable',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [
{
name: 'seaTableApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Row',
value: 'row',
},
],
default: 'row',
},
...rowOperations,
...rowFields,
],
};

View File

@@ -0,0 +1,158 @@
import moment from 'moment-timezone';
import {
type IPollFunctions,
type ILoadOptionsFunctions,
type INodeExecutionData,
type INodePropertyOptions,
type INodeType,
type INodeTypeDescription,
NodeConnectionType,
type INodeTypeBaseDescription,
} from 'n8n-workflow';
import { getColumns, rowFormatColumns, seaTableApiRequest, simplify } from './GenericFunctions';
import type { ICtx, IRow, IRowResponse } from './Interfaces';
export class SeaTableTriggerV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: 1,
subtitle: '={{$parameter["event"]}}',
defaults: {
name: 'SeaTable Trigger',
},
credentials: [
{
name: 'seaTableApi',
required: true,
},
],
polling: true,
inputs: [],
outputs: [NodeConnectionType.Main],
properties: [
{
displayName: 'Table Name or ID',
name: 'tableName',
type: 'options',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableNames',
},
default: '',
description:
'The name of SeaTable table to access. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Event',
name: 'event',
type: 'options',
options: [
{
name: 'Row Created',
value: 'rowCreated',
description: 'Trigger on newly created rows',
},
// {
// name: 'Row Modified',
// value: 'rowModified',
// description: 'Trigger has recently modified rows',
// },
],
default: 'rowCreated',
},
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
default: true,
description:
'Whether to return a simplified version of the response instead of the raw data',
},
],
};
}
methods = {
loadOptions: {
async getTableNames(this: ILoadOptionsFunctions) {
const returnData: INodePropertyOptions[] = [];
const {
metadata: { tables },
} = await seaTableApiRequest.call(
this,
{},
'GET',
'/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata',
);
for (const table of tables) {
returnData.push({
name: table.name,
value: table.name,
});
}
return returnData;
},
},
};
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
const webhookData = this.getWorkflowStaticData('node');
const tableName = this.getNodeParameter('tableName') as string;
const simple = this.getNodeParameter('simple') as boolean;
const event = this.getNodeParameter('event') as string;
const ctx: ICtx = {};
const credentials = await this.getCredentials('seaTableApi');
const timezone = (credentials.timezone as string) || 'Europe/Berlin';
const now = moment().utc().format();
const startDate = (webhookData.lastTimeChecked as string) || now;
const endDate = now;
webhookData.lastTimeChecked = endDate;
let rows;
const filterField = event === 'rowCreated' ? '_ctime' : '_mtime';
const endpoint = '/dtable-db/api/v1/query/{{dtable_uuid}}/';
if (this.getMode() === 'manual') {
rows = (await seaTableApiRequest.call(this, ctx, 'POST', endpoint, {
sql: `SELECT * FROM ${tableName} LIMIT 1`,
})) as IRowResponse;
} else {
rows = (await seaTableApiRequest.call(this, ctx, 'POST', endpoint, {
sql: `SELECT * FROM ${tableName}
WHERE ${filterField} BETWEEN "${moment(startDate).tz(timezone).format('YYYY-MM-D HH:mm:ss')}"
AND "${moment(endDate).tz(timezone).format('YYYY-MM-D HH:mm:ss')}"`,
})) as IRowResponse;
}
let response;
if (rows.metadata && rows.results) {
const columns = getColumns(rows);
if (simple) {
response = simplify(rows, columns);
} else {
response = rows.results;
}
const allColumns = rows.metadata.map((meta) => meta.name);
response = response
//@ts-ignore
.map((row: IRow) => rowFormatColumns(row, allColumns))
.map((row: IRow) => ({ json: row }));
}
if (Array.isArray(response) && response.length) {
return [response];
}
return null;
}
}

View File

@@ -0,0 +1,420 @@
import type {
IExecuteFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import {
getTableColumns,
getTableViews,
rowExport,
rowFormatColumns,
rowMapKeyToName,
seaTableApiRequest,
setableApiRequestAllItems,
split,
updateAble,
} from './GenericFunctions';
import type { ICtx, IRow, IRowObject } from './Interfaces';
import { versionDescription } from './SeaTable.node';
import type { TColumnsUiValues, TColumnValue } from './types';
export class SeaTableV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
methods = {
loadOptions: {
async getTableNames(this: ILoadOptionsFunctions) {
const returnData: INodePropertyOptions[] = [];
const {
metadata: { tables },
} = await seaTableApiRequest.call(
this,
{},
'GET',
'/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata/',
);
for (const table of tables) {
returnData.push({
name: table.name,
value: table.name,
});
}
return returnData;
},
async getTableIds(this: ILoadOptionsFunctions) {
const returnData: INodePropertyOptions[] = [];
const {
metadata: { tables },
} = await seaTableApiRequest.call(
this,
{},
'GET',
'/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata/',
);
for (const table of tables) {
returnData.push({
name: table.name,
value: table._id,
});
}
return returnData;
},
async getTableUpdateAbleColumns(this: ILoadOptionsFunctions) {
const tableName = this.getNodeParameter('tableName') as string;
const columns = await getTableColumns.call(this, tableName);
return columns
.filter((column) => column.editable)
.map((column) => ({ name: column.name, value: column.name }));
},
async getAllSortableColumns(this: ILoadOptionsFunctions) {
const tableName = this.getNodeParameter('tableName') as string;
const columns = await getTableColumns.call(this, tableName);
return columns
.filter(
(column) =>
!['file', 'image', 'url', 'collaborator', 'long-text'].includes(column.type),
)
.map((column) => ({ name: column.name, value: column.name }));
},
async getViews(this: ILoadOptionsFunctions) {
const tableName = this.getNodeParameter('tableName') as string;
const views = await getTableViews.call(this, tableName);
return views.map((view) => ({ name: view.name, value: view.name }));
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
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];
}
}

View File

@@ -0,0 +1,42 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow';
import { rowFields, rowOperations } from './RowDescription';
export const versionDescription: INodeTypeDescription = {
displayName: 'SeaTable',
name: 'seaTable',
icon: 'file:seaTable.svg',
group: ['input'],
version: 1,
subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}',
description: 'Consume the SeaTable API',
defaults: {
name: 'SeaTable',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [
{
name: 'seaTableApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Row',
value: 'row',
},
],
default: 'row',
},
...rowOperations,
...rowFields,
],
};

View File

@@ -0,0 +1,343 @@
import type FormData from 'form-data';
import type {
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
IPollFunctions,
JsonObject,
IHttpRequestMethods,
IHttpRequestOptions,
IRequestOptions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import type {
ICollaborator,
ICollaboratorsResult,
ICredential,
ICtx,
IDtableMetadataColumn,
IEndpointVariables,
IName,
IRow,
IRowObject,
IColumnDigitalSignature,
IFile,
} from './actions/Interfaces';
import { schema } from './Schema';
import type { TDtableMetadataColumns, TEndpointVariableName } from './types';
const userBaseUri = (uri?: string) => {
if (uri === undefined) return uri;
if (uri.endsWith('/')) return uri.slice(0, -1);
return uri;
};
export function resolveBaseUri(ctx: ICtx) {
return ctx?.credentials?.environment === 'cloudHosted'
? 'https://cloud.seatable.io'
: userBaseUri(ctx?.credentials?.domain);
}
export async function getBaseAccessToken(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
ctx: ICtx,
) {
if (ctx?.base?.access_token !== undefined) return;
const options: IHttpRequestOptions = {
headers: {
Authorization: `Token ${ctx?.credentials?.token}`,
},
url: `${resolveBaseUri(ctx)}/api/v2.1/dtable/app-access-token/`,
json: true,
};
ctx.base = await this.helpers.httpRequest(options);
}
function endpointCtxExpr(ctx: ICtx, endpoint: string): string {
const endpointVariables: IEndpointVariables = {};
endpointVariables.access_token = ctx?.base?.access_token;
endpointVariables.dtable_uuid = ctx?.base?.dtable_uuid;
return endpoint.replace(
/{{ *(access_token|dtable_uuid|server) *}}/g,
(match: string, name: TEndpointVariableName) => {
return (endpointVariables[name] as string) || match;
},
);
}
export async function seaTableApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
ctx: ICtx,
method: IHttpRequestMethods,
endpoint: string,
body: IDataObject | FormData | string | Buffer = {},
qs: IDataObject = {},
url: string = '',
option: IDataObject = {},
): Promise<any> {
const credentials = await this.getCredentials('seaTableApi');
ctx.credentials = credentials as unknown as ICredential;
await getBaseAccessToken.call(this, ctx);
// some API endpoints require the api_token instead of base_access_token.
const token =
endpoint.indexOf('/api/v2.1/dtable/app-download-link/') === 0 ||
endpoint == '/api/v2.1/dtable/app-upload-link/' ||
endpoint.indexOf('/seafhttp/upload-api') === 0
? `${ctx?.credentials?.token}`
: `${ctx?.base?.access_token}`;
let options: IRequestOptions = {
uri: url || `${resolveBaseUri(ctx)}${endpointCtxExpr(ctx, endpoint)}`,
headers: {
Authorization: `Token ${token}`,
},
method,
qs,
body,
json: true,
};
if (Object.keys(option).length !== 0) {
options = Object.assign({}, options, option);
}
// remove header from download request.
if (endpoint.indexOf('/seafhttp/files/') === 0) {
delete options.headers;
}
// enhance header for upload request
if (endpoint.indexOf('/seafhttp/upload-api') === 0) {
options.json = true;
options.headers = {
...options.headers,
'Content-Type': 'multipart/form-data',
};
}
if (Object.keys(body).length === 0) {
delete options.body;
}
try {
return await this.helpers.requestWithAuthentication.call(this, 'seaTableApi', options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
export async function getBaseCollaborators(
this: ILoadOptionsFunctions | IExecuteFunctions | IPollFunctions,
): Promise<any> {
const collaboratorsResult: ICollaboratorsResult = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/related-users/',
);
const collaborators: ICollaborator[] = collaboratorsResult.user_list || [];
return collaborators;
}
export async function getTableColumns(
this: ILoadOptionsFunctions | IExecuteFunctions | IPollFunctions,
tableName: string,
ctx: ICtx = {},
): Promise<TDtableMetadataColumns> {
const {
metadata: { tables },
} = await seaTableApiRequest.call(
this,
ctx,
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata',
);
for (const table of tables) {
if (table.name === tableName) {
return table.columns;
}
}
return [];
}
export function simplify_new(row: IRow) {
for (const key of Object.keys(row)) {
if (key.startsWith('_')) delete row[key];
}
return row;
}
const namePredicate = (name: string) => (named: IName) => named.name === name;
export const nameOfPredicate = (names: readonly IName[]) => (name: string) =>
names.find(namePredicate(name));
const normalize = (subject: string): string => (subject ? subject.normalize() : '');
export const split = (subject: string): string[] =>
normalize(subject)
.split(/\s*((?:[^\\,]*?(?:\\[\s\S])*)*?)\s*(?:,|$)/)
.filter((s) => s.length)
.map((s) => s.replace(/\\([\s\S])/gm, (_, $1) => $1));
function getCollaboratorInfo(
authLocal: string | null | undefined,
collaboratorList: ICollaborator[],
): ICollaborator {
return (
collaboratorList.find((singleCollaborator) => singleCollaborator.email === authLocal) || {
contact_email: 'unknown',
name: 'unknown',
email: 'unknown',
}
);
}
function getAssetPath(type: string, url: string) {
const parts = url.split(`/${type}/`);
if (parts[1]) {
return '/' + type + '/' + parts[1];
}
return url;
}
export function enrichColumns(
row: IRow,
metadata: IDtableMetadataColumn[],
collaboratorList: ICollaborator[],
): IRow {
Object.keys(row).forEach((key) => {
const columnDef = metadata.find((obj) => obj.name === key || obj.key === key);
if (columnDef?.type === 'collaborator') {
// collaborator is an array of strings.
const collaborators = (row[key] as string[]) || [];
if (collaborators.length > 0) {
const newArray = collaborators.map((email) => {
const collaboratorDetails = getCollaboratorInfo(email, collaboratorList);
const newColl = {
email,
contact_email: collaboratorDetails.contact_email,
name: collaboratorDetails.name,
};
return newColl;
});
row[key] = newArray;
}
}
if (
columnDef?.type === 'last-modifier' ||
columnDef?.type === 'creator' ||
columnDef?.key === '_creator' ||
columnDef?.key === '_last_modifier'
) {
// creator or last-modifier are always a single string.
const collaboratorDetails = getCollaboratorInfo(row[key] as string, collaboratorList);
row[key] = {
email: row[key],
contact_email: collaboratorDetails.contact_email,
name: collaboratorDetails.name,
};
}
if (columnDef?.type === 'image') {
const pictures = (row[key] as string[]) || [];
if (pictures.length > 0) {
const newArray = pictures.map((url) => ({
name: url.split('/').pop(),
size: 0,
type: 'image',
url,
path: getAssetPath('images', url),
}));
row[key] = newArray;
}
}
if (columnDef?.type === 'file') {
const files = (row[key] as IFile[]) || [];
files.forEach((file) => {
file.path = getAssetPath('files', file.url);
});
}
if (columnDef?.type === 'digital-sign') {
const digitalSignature: IColumnDigitalSignature | any = row[key];
const collaboratorDetails = getCollaboratorInfo(digitalSignature?.username, collaboratorList);
if (digitalSignature?.username) {
digitalSignature.contact_email = collaboratorDetails.contact_email;
digitalSignature.name = collaboratorDetails.name;
}
}
if (columnDef?.type === 'button') {
delete row[key];
}
});
return row;
}
export function splitStringColumnsToArrays(
row: IRowObject,
columns: TDtableMetadataColumns,
): IRowObject {
columns.map((column) => {
if (column.type === 'collaborator' || column.type === 'multiple-select') {
if (typeof row[column.name] === 'string') {
const input = row[column.name] as string;
row[column.name] = input.split(',').map((item) => item.trim());
}
}
if (column.type === 'number') {
if (typeof row[column.name] === 'string') {
const input = row[column.name] as string;
row[column.name] = parseFloat(input);
}
}
if (column.type === 'rate' || column.type === 'duration') {
if (typeof row[column.name] === 'string') {
const input = row[column.name] as string;
row[column.name] = parseInt(input);
}
}
if (column.type === 'checkbox') {
if (typeof row[column.name] === 'string') {
const input = row[column.name] as string;
row[column.name] = false;
if (input === 'true' || input === 'on' || input === '1') {
row[column.name] = true;
}
}
}
});
return row;
}
export function rowExport(row: IRowObject, columns: TDtableMetadataColumns): IRowObject {
const rowAllowed = {} as IRowObject;
columns.map((column) => {
if (row[column.name]) {
rowAllowed[column.name] = row[column.name];
}
});
return rowAllowed;
}
export const dtableSchemaIsColumn = (column: IDtableMetadataColumn): boolean =>
!!schema.columnTypes[column.type];
const dtableSchemaIsUpdateAbleColumn = (column: IDtableMetadataColumn): boolean =>
!!schema.columnTypes[column.type] && !schema.nonUpdateAbleColumnTypes[column.type];
export const dtableSchemaColumns = (columns: TDtableMetadataColumns): TDtableMetadataColumns =>
columns.filter(dtableSchemaIsColumn);
export const updateAble = (columns: TDtableMetadataColumns): TDtableMetadataColumns =>
columns.filter(dtableSchemaIsUpdateAbleColumn);

View File

@@ -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 };
};

View File

@@ -0,0 +1,292 @@
import moment from 'moment-timezone';
import {
type IPollFunctions,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
NodeConnectionType,
type INodeTypeBaseDescription,
type IDataObject,
} from 'n8n-workflow';
import type {
ICtx,
IRow,
IRowResponse,
IGetMetadataResult,
IGetRowsResult,
IDtableMetadataColumn,
ICollaborator,
ICollaboratorsResult,
IColumnDigitalSignature,
} from './actions/Interfaces';
import { seaTableApiRequest, simplify_new, enrichColumns } from './GenericFunctions';
import { loadOptions } from './methods';
export class SeaTableTriggerV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: 2,
subtitle: '={{$parameter["event"]}}',
defaults: {
name: 'SeaTable Trigger',
},
credentials: [
{
name: 'seaTableApi',
required: true,
},
],
polling: true,
inputs: [],
outputs: [NodeConnectionType.Main],
properties: [
{
displayName: 'Event',
name: 'event',
type: 'options',
options: [
{
name: 'New Row',
value: 'newRow',
description: 'Trigger on newly created rows',
},
{
name: 'New or Updated Row',
value: 'updatedRow',
description: 'Trigger has recently created or modified rows',
},
{
name: 'New Signature',
value: 'newAsset',
description: 'Trigger on new signatures',
},
],
default: 'newRow',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Table Name',
name: 'tableName',
type: 'options',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableNames',
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'The name of SeaTable table to access. Choose from the list, or specify the name using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'View Name',
name: 'viewName',
type: 'options',
displayOptions: {
show: {
event: ['newRow', 'updatedRow'],
},
},
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getTableViews',
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'The name of SeaTable view to access. Choose from the list, or specify the name using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Signature Column',
name: 'assetColumn',
type: 'options',
required: true,
displayOptions: {
show: {
event: ['newAsset'],
},
},
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getSignatureColumns',
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'Select the digital-signature column that should be tracked. Choose from the list, or specify the name using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
default: true,
description:
'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Return Column Names',
name: 'convert',
type: 'boolean',
default: true,
description: 'Whether to return the column keys (false) or the column names (true)',
displayOptions: {
show: {
'/event': ['newRow', 'updatedRow'],
},
},
},
],
},
{
displayName: '"Fetch Test Event" returns max. three items of the last hour.',
name: 'notice',
type: 'notice',
default: '',
},
],
};
}
methods = { loadOptions };
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
const webhookData = this.getWorkflowStaticData('node');
const event = this.getNodeParameter('event') as string;
const tableName = this.getNodeParameter('tableName') as string;
const viewName = (event !== 'newAsset' ? this.getNodeParameter('viewName') : '') as string;
const assetColumn = (
event === 'newAsset' ? this.getNodeParameter('assetColumn') : ''
) as string;
const options = this.getNodeParameter('options') as IDataObject;
const ctx: ICtx = {};
const startDate =
this.getMode() === 'manual'
? moment().utc().subtract(1, 'h').format()
: (webhookData.lastTimeChecked as string);
const endDate = (webhookData.lastTimeChecked = moment().utc().format());
const filterField = event === 'newRow' ? '_ctime' : '_mtime';
let requestMeta: IGetMetadataResult;
let requestRows: IGetRowsResult;
let metadata: IDtableMetadataColumn[] = [];
let rows: IRow[];
let sqlResult: IRowResponse;
const limit = this.getMode() === 'manual' ? 3 : 1000;
// New Signature
if (event === 'newAsset') {
const endpoint = '/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql';
sqlResult = await seaTableApiRequest.call(this, ctx, 'POST', endpoint, {
sql: `SELECT _id, _ctime, _mtime, \`${assetColumn}\` FROM ${tableName} WHERE \`${assetColumn}\` IS NOT NULL ORDER BY _mtime DESC LIMIT ${limit}`,
convert_keys: options.convert ?? true,
});
metadata = sqlResult.metadata as IDtableMetadataColumn[];
const columnType = metadata.find((obj) => obj.name == assetColumn);
const assetColumnType = columnType?.type || null;
// remove unwanted entries
rows = sqlResult.results.filter((obj) => new Date(obj._mtime) > new Date(startDate));
// split the objects into new lines (not necessary for digital-sign)
const newRows: any = [];
for (const row of rows) {
if (assetColumnType === 'digital-sign') {
const signature = (row[assetColumn] as IColumnDigitalSignature) || [];
if (signature.sign_time) {
if (new Date(signature.sign_time) > new Date(startDate)) {
newRows.push(signature);
}
}
}
}
}
// View => use getRows.
else if (viewName || viewName === '') {
requestMeta = await seaTableApiRequest.call(
this,
ctx,
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata/',
);
requestRows = await seaTableApiRequest.call(
this,
ctx,
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/',
{},
{
table_name: tableName,
view_name: viewName,
limit,
convert_keys: options.convert ?? true,
},
);
metadata =
requestMeta.metadata.tables.find((table) => table.name === tableName)?.columns ?? [];
// remove unwanted rows that are too old (compare startDate with _ctime or _mtime)
if (this.getMode() === 'manual') {
rows = requestRows.rows;
} else {
rows = requestRows.rows.filter((obj) => new Date(obj[filterField]) > new Date(startDate));
}
} else {
const endpoint = '/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql';
const sqlQuery = `SELECT * FROM \`${tableName}\` WHERE ${filterField} BETWEEN "${moment(
startDate,
).format('YYYY-MM-D HH:mm:ss')}" AND "${moment(endDate).format(
'YYYY-MM-D HH:mm:ss',
)}" ORDER BY ${filterField} DESC LIMIT ${limit}`;
sqlResult = await seaTableApiRequest.call(this, ctx, 'POST', endpoint, {
sql: sqlQuery,
convert_keys: options.convert ?? true,
});
metadata = sqlResult.metadata as IDtableMetadataColumn[];
rows = sqlResult.results;
}
const collaboratorsResult: ICollaboratorsResult = await seaTableApiRequest.call(
this,
ctx,
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/related-users/',
);
const collaborators: ICollaborator[] = collaboratorsResult.user_list || [];
if (Array.isArray(rows) && rows.length > 0) {
const simple = options.simple ?? true;
// remove columns starting with _ if simple;
if (simple) {
rows = rows.map((row) => simplify_new(row));
}
// enrich column types like {collaborator, creator, last_modifier}, {image, file}
// remove button column from rows
rows = rows.map((row) => enrichColumns(row, metadata, collaborators));
// prepare for final output
return [this.helpers.returnJsonArray(rows)];
}
return null;
}
}

View File

@@ -0,0 +1,27 @@
import type {
IExecuteFunctions,
INodeType,
INodeTypeDescription,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import { router } from './actions/router';
import { versionDescription } from './actions/SeaTable.node';
import { loadOptions } from './methods';
export class SeaTableV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
methods = { loadOptions };
async execute(this: IExecuteFunctions) {
return await router.call(this);
}
}

View File

@@ -0,0 +1,195 @@
import type { AllEntities, Entity, PropertiesOf } from 'n8n-workflow';
type SeaTableMap = {
row: 'create' | 'get' | 'search' | 'update' | 'remove' | 'lock' | 'unlock' | 'list';
base: 'snapshot' | 'metadata' | 'collaborator';
link: 'add' | 'list' | 'remove';
asset: 'upload' | 'getPublicURL';
};
export type SeaTable = AllEntities<SeaTableMap>;
export type SeaTableRow = Entity<SeaTableMap, 'row'>;
export type SeaTableBase = Entity<SeaTableMap, 'base'>;
export type SeaTableLink = Entity<SeaTableMap, 'link'>;
export type SeaTableAsset = Entity<SeaTableMap, 'asset'>;
export type RowProperties = PropertiesOf<SeaTableRow>;
export type BaseProperties = PropertiesOf<SeaTableBase>;
export type LinkProperties = PropertiesOf<SeaTableLink>;
export type AssetProperties = PropertiesOf<SeaTableAsset>;
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;
}

View File

@@ -0,0 +1,58 @@
import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow';
import * as asset from './asset';
import * as base from './base';
import * as link from './link';
import * as row from './row';
export const versionDescription: INodeTypeDescription = {
displayName: 'SeaTable',
name: 'seaTable',
icon: 'file:seaTable.svg',
group: ['output'],
version: 2,
subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}',
description: 'Consume the SeaTable API',
defaults: {
name: 'SeaTable',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [
{
name: 'seaTableApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Row',
value: 'row',
},
{
name: 'Base',
value: 'base',
},
{
name: 'Link',
value: 'link',
},
{
name: 'Asset',
value: 'asset',
},
],
default: 'row',
},
...row.descriptions,
...base.descriptions,
...link.descriptions,
...asset.descriptions,
],
};

View File

@@ -0,0 +1,48 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
const properties: INodeProperties[] = [
{
displayName: 'Asset Path',
name: 'assetPath',
type: 'string',
placeholder: '/images/2023-09/logo.png',
required: true,
default: '',
},
];
const displayOptions = {
show: {
resource: ['asset'],
operation: ['getPublicURL'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
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);
}

View File

@@ -0,0 +1,37 @@
import type { INodeProperties } from 'n8n-workflow';
import * as getPublicURL from './getPublicURL.operation';
import * as upload from './upload.operation';
export { upload, getPublicURL };
export const descriptions: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['asset'],
},
},
options: [
{
name: 'Public URL',
value: 'getPublicURL',
description: 'Get the public URL from asset path',
action: 'Get the public URL from asset path',
},
{
name: 'Upload',
value: 'upload',
description: 'Add a file/image to an existing row',
action: 'Upload a file or image',
},
],
default: 'upload',
},
...upload.description,
...getPublicURL.description,
];

View File

@@ -0,0 +1,232 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
import type { IUploadLink, IRowObject } from '../Interfaces';
const properties: INodeProperties[] = [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Table Name',
name: 'tableName',
type: 'options',
placeholder: 'Select a table',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableNames',
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'Choose from the list, or specify a name using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Column Name',
name: 'uploadColumn',
type: 'options',
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getAssetColumns',
},
required: true,
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'Choose from the list, or specify the name using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Row ID',
name: 'rowId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
required: true,
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getRowIds',
},
default: '',
},
{
displayName: 'Property Name',
name: 'dataPropertyName',
type: 'string',
default: 'data',
required: true,
description: 'Name of the binary property which contains the data for the file to be written',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Replace Existing File',
name: 'replace',
type: 'boolean',
default: true,
description:
'Whether to replace the existing asset with the same name (true). Otherwise, a new version with a different name (numeral in parentheses) will be uploaded (false).',
},
{
displayName: 'Append to Column',
name: 'append',
type: 'boolean',
default: true,
description:
'Whether to keep existing files/images in the column and append the new asset (true). Otherwise, the existing files/images are removed from the column (false).',
},
],
},
];
const displayOptions = {
show: {
resource: ['asset'],
operation: ['upload'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const uploadColumn = this.getNodeParameter('uploadColumn', index) as string;
const uploadColumnType = uploadColumn.split(':::')[1];
const uploadColumnName = uploadColumn.split(':::')[0];
const dataPropertyName = this.getNodeParameter('dataPropertyName', index);
const tableName = this.getNodeParameter('tableName', index) as string;
const rowId = this.getNodeParameter('rowId', index) as string;
const uploadLink = (await seaTableApiRequest.call(
this,
{},
'GET',
'/api/v2.1/dtable/app-upload-link/',
)) as IUploadLink;
const relativePath =
uploadColumnType === 'image' ? uploadLink.img_relative_path : uploadLink.file_relative_path;
const options = this.getNodeParameter('options', index) as IDataObject;
// get server url
const credentials: any = await this.getCredentials('seaTableApi');
const serverURL: string = credentials.domain
? credentials.domain.replace(/\/$/, '')
: 'https://cloud.seatable.io';
// get workspaceId
const workspaceId = (
await this.helpers.httpRequest({
headers: {
Authorization: `Token ${credentials.token}`,
},
url: `${serverURL}/api/v2.1/dtable/app-access-token/`,
json: true,
})
).workspace_id;
// if there are already assets attached to the column
let existingAssetArray = [];
const append = options.append ?? true;
if (append) {
const rowToUpdate = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/' + rowId,
{},
{
table_name: tableName,
convert_keys: true,
},
);
existingAssetArray = rowToUpdate[uploadColumnName] ?? [];
}
// Get the binary data and prepare asset for upload
const fileBufferData = await this.helpers.getBinaryDataBuffer(index, dataPropertyName);
const binaryData = this.helpers.assertBinaryData(index, dataPropertyName);
const requestOptions = {
formData: {
file: {
value: fileBufferData,
options: {
filename: binaryData.fileName,
contentType: binaryData.mimeType,
},
},
parent_dir: uploadLink.parent_path,
replace: options.replace ? '1' : '0',
relative_path: relativePath,
},
};
// Send the upload request
const uploadAsset = await seaTableApiRequest.call(
this,
{},
'POST',
`/seafhttp/upload-api/${uploadLink.upload_link.split('seafhttp/upload-api/')[1]}?ret-json=true`,
{},
{},
'',
requestOptions,
);
// attach the asset to a column in a base
for (let c = 0; c < uploadAsset.length; c++) {
const rowInput = {} as IRowObject;
const filePath = `${serverURL}/workspace/${workspaceId}${uploadLink.parent_path}/${relativePath}/${uploadAsset[c].name}`;
if (uploadColumnType === 'image') {
rowInput[uploadColumnName] = [filePath];
} else if (uploadColumnType === 'file') {
rowInput[uploadColumnName] = uploadAsset;
uploadAsset[c].type = 'file';
uploadAsset[c].url = filePath;
}
// merge with existing assets in this column or with [] and remove duplicates
const mergedArray = existingAssetArray.concat(rowInput[uploadColumnName]);
// Remove duplicates from input, keeping the last one
const uniqueAssets = Array.from(new Set(mergedArray));
// Update the rowInput with the unique assets and store into body.row.
rowInput[uploadColumnName] = uniqueAssets;
const body = {
table_name: tableName,
updates: [
{
row_id: rowId,
row: rowInput,
},
],
} as IDataObject;
// attach assets to table row
const responseData = await seaTableApiRequest.call(
this,
{},
'PUT',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/',
body,
);
uploadAsset[c].upload_successful = responseData.success;
}
return this.helpers.returnJsonArray(uploadAsset as IDataObject[]);
}

View File

@@ -0,0 +1,54 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
import type { ICollaborator } from '../Interfaces';
export const properties: INodeProperties[] = [
{
displayName: 'Name or email of the collaborator',
name: 'searchString',
type: 'string',
placeholder: 'Enter the name or the email or the collaborator',
required: true,
default: '',
description:
'SeaTable identifies users with a unique username like 244b43hr6fy54bb4afa2c2cb7369d244@auth.local. Get this username from an email or the name of a collaborator.',
},
];
const displayOptions = {
show: {
resource: ['base'],
operation: ['collaborator'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const searchString = this.getNodeParameter('searchString', index) as string;
const collaboratorsResult = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/related-users/',
);
const collaborators = collaboratorsResult.user_list || [];
const data = collaborators.filter(
(col: ICollaborator) =>
col.contact_email.includes(searchString) || col.name.includes(searchString),
);
return this.helpers.returnJsonArray(data as IDataObject[]);
}

View File

@@ -0,0 +1,45 @@
import type { INodeProperties } from 'n8n-workflow';
import * as collaborator from './collaborator.operation';
import * as metadata from './metadata.operation';
import * as snapshot from './snapshot.operation';
export { snapshot, metadata, collaborator };
export const descriptions: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['base'],
},
},
options: [
{
name: 'Snapshot',
value: 'snapshot',
description: 'Create a snapshot of the base',
action: 'Create a snapshot',
},
{
name: 'Metadata',
value: 'metadata',
description: 'Get the complete metadata of the base',
action: 'Get metadata of a base',
},
{
name: 'Collaborator',
value: 'collaborator',
description: 'Get the username from the email or name of a collaborator',
action: 'Get username from email or name',
},
],
default: 'snapshot',
},
...snapshot.description,
...metadata.description,
...collaborator.description,
];

View File

@@ -0,0 +1,30 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
export const properties: INodeProperties[] = [];
const displayOptions = {
show: {
resource: ['base'],
operation: ['metadata'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
const responseData = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata/',
);
return this.helpers.returnJsonArray(responseData.metadata as IDataObject[]);
}

View File

@@ -0,0 +1,32 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
export const properties: INodeProperties[] = [];
const displayOptions = {
show: {
resource: ['base'],
operation: ['snapshot'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
const responseData = await seaTableApiRequest.call(
this,
{},
'POST',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/snapshot/',
{ dtable_name: 'snapshot' },
);
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}

View File

@@ -0,0 +1,96 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
export const properties: INodeProperties[] = [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Table Name (Source)',
name: 'tableName',
type: 'options',
placeholder: 'Name of table',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableNameAndId',
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'Choose from the list, of specify by using an expression. Provide it in the way "table_name:::table_id".',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Link Column',
name: 'linkColumn',
type: 'options',
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getLinkColumns',
},
required: true,
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'Choose from the list of specify the Link Column by using an expression. You have to provide it in the way "column_name:::link_id:::other_table_id".',
},
{
displayName: 'Row ID From the Source Table',
name: 'linkColumnSourceId',
type: 'string',
required: true,
default: '',
description: 'Provide the row ID of table you selected',
},
{
displayName: 'Row ID From the Target',
name: 'linkColumnTargetId',
type: 'string',
required: true,
default: '',
description: 'Provide the row ID of table you want to link',
},
];
const displayOptions = {
show: {
resource: ['link'],
operation: ['add'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const tableName = this.getNodeParameter('tableName', index) as string;
const linkColumn = this.getNodeParameter('linkColumn', index) as any;
const linkColumnSourceId = this.getNodeParameter('linkColumnSourceId', index) as string;
const linkColumnTargetId = this.getNodeParameter('linkColumnTargetId', index) as string;
const body = {
link_id: linkColumn.split(':::')[1],
table_id: tableName.split(':::')[1],
other_table_id: linkColumn.split(':::')[2],
other_rows_ids_map: {
[linkColumnSourceId]: [linkColumnTargetId],
},
};
const responseData = await seaTableApiRequest.call(
this,
{},
'POST',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/links/',
body,
);
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}

View File

@@ -0,0 +1,45 @@
import type { INodeProperties } from 'n8n-workflow';
import * as add from './add.operation';
import * as list from './list.operation';
import * as remove from './remove.operation';
export { add, list, remove };
export const descriptions: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['link'],
},
},
options: [
{
name: 'Add',
value: 'add',
description: 'Create a link between two rows in a link column',
action: 'Add a row link',
},
{
name: 'List',
value: 'list',
description: 'List all links of a specific row',
action: 'List row links',
},
{
name: 'Remove',
value: 'remove',
description: 'Remove a link between two rows from a link column',
action: 'Remove a row link',
},
],
default: 'add',
},
...add.description,
...list.description,
...remove.description,
];

View File

@@ -0,0 +1,92 @@
/* eslint-disable n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options */
/* eslint-disable n8n-nodes-base/node-param-description-wrong-for-dynamic-options */
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
export const properties: INodeProperties[] = [
{
displayName: 'Table Name',
name: 'tableName',
type: 'options',
placeholder: 'Select a table',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableNameAndId',
},
default: '',
description:
'Choose from the list, of specify by using an expression. Provide it in the way "table_name:::table_id".',
},
{
displayName: 'Link Column',
name: 'linkColumn',
type: 'options',
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getLinkColumnsWithColumnKey',
},
required: true,
default: '',
description:
'Choose from the list of specify the Link Column by using an expression. You have to provide it in the way "column_name:::link_id:::other_table_id:::column_key".',
},
{
displayName: 'Row ID',
name: 'rowId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
required: true,
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getRowIds',
},
default: '',
},
];
const displayOptions = {
show: {
resource: ['link'],
operation: ['list'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
// get parameters
const tableName = this.getNodeParameter('tableName', index) as string;
const linkColumn = this.getNodeParameter('linkColumn', index) as string;
const rowId = this.getNodeParameter('rowId', index) as string;
// get rows
const responseData = await seaTableApiRequest.call(
this,
{},
'POST',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/query-links/',
{
table_id: tableName.split(':::')[1],
link_column_key: linkColumn.split(':::')[3],
rows: [
{
row_id: rowId,
offset: 0,
limit: 100,
},
],
},
);
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}

View File

@@ -0,0 +1,94 @@
/* eslint-disable n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options */
/* eslint-disable n8n-nodes-base/node-param-description-wrong-for-dynamic-options */
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
export const properties: INodeProperties[] = [
{
displayName: 'Table Name (Source)',
name: 'tableName',
type: 'options',
placeholder: 'Name of table',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableNameAndId',
},
default: '',
description:
'Choose from the list, of specify by using an expression. Provide it in the way "table_name:::table_id".',
},
{
displayName: 'Link Column',
name: 'linkColumn',
type: 'options',
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getLinkColumns',
},
required: true,
default: '',
description:
'Choose from the list of specify the Link Column by using an expression. You have to provide it in the way "column_name:::link_id:::other_table_id".',
},
{
displayName: 'Row ID From the Source Table',
name: 'linkColumnSourceId',
type: 'string',
required: true,
default: '',
description: 'Provide the row ID of table you selected',
},
{
displayName: 'Row ID From the Target Table',
name: 'linkColumnTargetId',
type: 'string',
required: true,
default: '',
description: 'Provide the row ID of table you want to link',
},
];
const displayOptions = {
show: {
resource: ['link'],
operation: ['remove'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const tableName = this.getNodeParameter('tableName', index) as string;
const linkColumn = this.getNodeParameter('linkColumn', index) as any;
const linkColumnSourceId = this.getNodeParameter('linkColumnSourceId', index) as string;
const linkColumnTargetId = this.getNodeParameter('linkColumnTargetId', index) as string;
const body = {
link_id: linkColumn.split(':::')[1],
table_id: tableName.split(':::')[1],
other_table_id: linkColumn.split(':::')[2],
other_rows_ids_map: {
[linkColumnSourceId]: [linkColumnTargetId],
},
};
const responseData = await seaTableApiRequest.call(
this,
{},
'DELETE',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/links/',
body,
);
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}

View File

@@ -0,0 +1,53 @@
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
import * as asset from './asset';
import * as base from './base';
import type { SeaTable } from './Interfaces';
import * as link from './link';
import * as row from './row';
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const operationResult: INodeExecutionData[] = [];
let responseData: IDataObject | IDataObject[] = [];
for (let i = 0; i < items.length; i++) {
const resource = this.getNodeParameter<SeaTable>('resource', i);
const operation = this.getNodeParameter('operation', i);
const seatable = {
resource,
operation,
} as SeaTable;
try {
if (seatable.resource === 'row') {
responseData = await row[seatable.operation].execute.call(this, i);
} else if (seatable.resource === 'base') {
responseData = await base[seatable.operation].execute.call(this, i);
} else if (seatable.resource === 'link') {
responseData = await link[seatable.operation].execute.call(this, i);
} else if (seatable.resource === 'asset') {
responseData = await asset[seatable.operation].execute.call(this, i);
}
const executionData = this.helpers.constructExecutionMetaData(
responseData as INodeExecutionData[],
{
itemData: { item: i },
},
);
operationResult.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
operationResult.push({ json: this.getInputData(i)[0].json, error });
} else {
if (error.context) error.context.itemIndex = i;
throw error;
}
}
}
return [operationResult];
}

View File

@@ -0,0 +1,219 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import {
seaTableApiRequest,
getTableColumns,
split,
rowExport,
updateAble,
splitStringColumnsToArrays,
} from '../../GenericFunctions';
import type { TColumnValue, TColumnsUiValues } from '../../types';
import type { IRowObject } from '../Interfaces';
export const properties: INodeProperties[] = [
{
displayName: 'Data to Send',
name: 'fieldsToSend',
type: 'options',
options: [
{
name: 'Auto-Map Input Data to Columns',
value: 'autoMapInputData',
description: 'Use when node input properties match destination column names',
},
{
name: 'Define Below for Each Column',
value: 'defineBelow',
description: 'Set the value for each destination column',
},
],
default: 'defineBelow',
description: 'Whether to insert the input data this node receives in the new row',
},
{
displayName: 'Apply Column Default Values',
name: 'apply_default',
type: 'boolean',
default: false,
description:
'Whether to use the column default values to populate new rows during creation (only available for normal backend)',
displayOptions: {
show: {
bigdata: [false],
},
},
},
{
displayName:
'In this mode, make sure the incoming data fields are named the same as the columns in SeaTable. (Use an "Edit Fields" node before this node to change them if required.)',
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
'/fieldsToSend': ['autoMapInputData'],
},
},
},
{
displayName: 'Inputs to Ignore',
name: 'inputsToIgnore',
type: 'string',
default: '',
description:
'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.',
placeholder: 'Enter properties...',
displayOptions: {
show: {
'/fieldsToSend': ['autoMapInputData'],
},
},
},
{
displayName: 'Columns to Send',
name: 'columnsUi',
placeholder: 'Add Column',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Column to Send',
multipleValues: true,
},
displayOptions: {
show: {
'/fieldsToSend': ['defineBelow'],
},
},
options: [
{
displayName: 'Column',
name: 'columnValues',
values: [
{
displayName: 'Column Name or ID',
name: 'columnName',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getTableUpdateAbleColumns',
},
default: '',
},
{
displayName: 'Column Value',
name: 'columnValue',
type: 'string',
default: '',
},
],
},
],
default: {},
description:
'Add destination column with its value. Provide the value in this way. Date: YYYY-MM-DD or YYYY-MM-DD hh:mm. Duration: time in seconds. Checkbox: true, on or 1. Multi-Select: comma-separated list.',
},
{
displayName: 'Save to "Big Data" Backend',
name: 'bigdata',
type: 'boolean',
default: false,
description:
'Whether write to Big Data backend (true) or not (false). True requires the activation of the Big Data backend in the base.',
},
{
displayName:
'Hint: Link, files, images or digital signatures have to be added separately. These column types cannot be set with this node.',
name: 'notice',
type: 'notice',
default: '',
},
];
const displayOptions = {
show: {
resource: ['row'],
operation: ['create'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const tableName = this.getNodeParameter('tableName', index) as string;
const tableColumns = await getTableColumns.call(this, tableName);
const fieldsToSend = this.getNodeParameter('fieldsToSend', index) as
| 'defineBelow'
| 'autoMapInputData';
const bigdata = this.getNodeParameter('bigdata', index) as boolean;
const apply_default = this.getNodeParameter('apply_default', index, false) as boolean;
const body = {
table_name: tableName,
rows: {},
} as IDataObject;
let rowInput = {} as IRowObject;
// get rowInput, an object of key:value pairs like { Name: 'Promo Action 1', Status: "Draft" }.
if (fieldsToSend === 'autoMapInputData') {
const items = this.getInputData();
const incomingKeys = Object.keys(items[index].json);
const inputDataToIgnore = split(this.getNodeParameter('inputsToIgnore', index, '') as string);
for (const key of incomingKeys) {
if (inputDataToIgnore.includes(key)) continue;
rowInput[key] = items[index].json[key] as TColumnValue;
}
} else {
const columns = this.getNodeParameter('columnsUi.columnValues', index, []) as TColumnsUiValues;
for (const column of columns) {
rowInput[column.columnName] = column.columnValue;
}
}
// only keep key:value pairs for columns that are allowed to update.
rowInput = rowExport(rowInput, updateAble(tableColumns));
// string to array: multi-select and collaborators
rowInput = splitStringColumnsToArrays(rowInput, tableColumns);
// save to big data backend
if (bigdata) {
body.rows = [rowInput];
const responseData = await seaTableApiRequest.call(
this,
{},
'POST',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/add-archived-rows/',
body,
);
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}
// save to normal backend
else {
body.rows = [rowInput];
if (apply_default) {
body.apply_default = true;
}
const responseData = await seaTableApiRequest.call(
this,
{},
'POST',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/',
body,
);
if (responseData.first_row) {
return this.helpers.returnJsonArray(responseData.first_row as IDataObject[]);
}
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}
}

View File

@@ -0,0 +1,88 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import {
seaTableApiRequest,
enrichColumns,
simplify_new,
getBaseCollaborators,
} from '../../GenericFunctions';
import type { IRowResponse, IDtableMetadataColumn } from '../Interfaces';
export const properties: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
default: true,
description:
'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Return Column Names',
name: 'convert',
type: 'boolean',
default: true,
description: 'Whether to return the column keys (false) or the column names (true)',
},
],
},
];
const displayOptions = {
show: {
resource: ['row'],
operation: ['get'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
// get parameters
const tableName = this.getNodeParameter('tableName', index) as string;
const rowId = this.getNodeParameter('rowId', index) as string;
const options = this.getNodeParameter('options', index) as IDataObject;
// get collaborators
const collaborators = await getBaseCollaborators.call(this);
// get rows
const sqlResult = (await seaTableApiRequest.call(
this,
{},
'POST',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql/',
{
sql: `SELECT * FROM \`${tableName}\` WHERE _id = '${rowId}'`,
convert_keys: options.convert ?? true,
},
)) as IRowResponse;
const metadata = sqlResult.metadata as IDtableMetadataColumn[];
const rows = sqlResult.results;
// hide columns like button
rows.map((row) => enrichColumns(row, metadata, collaborators));
const simple = options.simple ?? true;
// remove columns starting with _ if simple;
if (simple) {
rows.map((row) => simplify_new(row));
}
return this.helpers.returnJsonArray(rows as IDataObject[]);
}

View File

@@ -0,0 +1,84 @@
import type { INodeProperties } from 'n8n-workflow';
import * as create from './create.operation';
import * as get from './get.operation';
import * as list from './list.operation';
import * as lock from './lock.operation';
import * as remove from './remove.operation';
import * as search from './search.operation';
import { sharedProperties } from './sharedProperties';
import * as unlock from './unlock.operation';
import * as update from './update.operation';
export { create, get, search, update, remove, lock, unlock, list };
export const descriptions: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['row'],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a new row',
action: 'Create a row',
},
{
name: 'Delete',
value: 'remove',
description: 'Delete a row',
action: 'Delete a row',
},
{
name: 'Get',
value: 'get',
description: 'Get the content of a row',
action: 'Get a row',
},
{
name: 'Get Many',
value: 'list',
description: 'Get many rows from a table or a table view',
action: 'Get many rows',
},
{
name: 'Lock',
value: 'lock',
description: 'Lock a row to prevent further changes',
action: 'Add a row lock',
},
{
name: 'Search',
value: 'search',
description: 'Search one or multiple rows',
action: 'Search a row by keyword',
},
{
name: 'Unlock',
value: 'unlock',
description: 'Remove the lock from a row',
action: 'Remove a row lock',
},
{
name: 'Update',
value: 'update',
description: 'Update the content of a row',
action: 'Update a row',
},
],
default: 'create',
},
...sharedProperties,
...create.description,
...get.description,
...list.description,
...search.description,
...update.description,
];

View File

@@ -0,0 +1,116 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import {
seaTableApiRequest,
enrichColumns,
simplify_new,
getBaseCollaborators,
} from '../../GenericFunctions';
import type { IRow } from '../Interfaces';
export const properties: INodeProperties[] = [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'View Name',
name: 'viewName',
type: 'options',
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getTableViews',
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'The name of SeaTable view to access, or specify by using an expression. Provide it in the way "col.name:::col.type".',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
default: true,
description:
'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Return Column Names',
name: 'convert',
type: 'boolean',
default: true,
description: 'Whether to return the column keys (false) or the column names (true)',
},
],
},
];
const displayOptions = {
show: {
resource: ['row'],
operation: ['list'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
// get parameters
const tableName = this.getNodeParameter('tableName', index) as string;
const viewName = this.getNodeParameter('viewName', index) as string;
const options = this.getNodeParameter('options', index) as IDataObject;
// get collaborators
const collaborators = await getBaseCollaborators.call(this);
// get rows
const requestMeta = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata/',
);
const requestRows = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/',
{},
{
table_name: tableName,
view_name: viewName,
limit: 1000,
convert_keys: options.convert ?? true,
},
);
const metadata =
requestMeta.metadata.tables.find((table: { name: string }) => table.name === tableName)
?.columns ?? [];
const rows = requestRows.rows as IRow[];
// hide columns like button
rows.map((row) => enrichColumns(row, metadata, collaborators));
const simple = options.simple ?? true;
// remove columns starting with _ if simple;
if (simple) {
rows.map((row) => simplify_new(row));
}
return this.helpers.returnJsonArray(rows as IDataObject[]);
}

View File

@@ -0,0 +1,24 @@
import type { IDataObject, INodeExecutionData, IExecuteFunctions } from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const tableName = this.getNodeParameter('tableName', index) as string;
const rowId = this.getNodeParameter('rowId', index) as string;
const responseData = await seaTableApiRequest.call(
this,
{},
'PUT',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/lock-rows/',
{
table_name: tableName,
row_ids: [rowId],
},
);
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}

View File

@@ -0,0 +1,26 @@
import type { IDataObject, INodeExecutionData, IExecuteFunctions } from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const tableName = this.getNodeParameter('tableName', index) as string;
const rowId = this.getNodeParameter('rowId', index) as string;
const requestBody: IDataObject = {
table_name: tableName,
row_ids: [rowId],
};
const responseData = await seaTableApiRequest.call(
this,
{},
'DELETE',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/',
requestBody,
);
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}

View File

@@ -0,0 +1,139 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import {
seaTableApiRequest,
enrichColumns,
simplify_new,
getBaseCollaborators,
} from '../../GenericFunctions';
import type { IDtableMetadataColumn, IRowResponse } from '../Interfaces';
export const properties: INodeProperties[] = [
{
displayName: 'Column Name or ID',
name: 'searchColumn',
type: 'options',
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getSearchableColumns',
},
required: true,
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
'Select the column to be searched. Not all column types are supported for search. Choose from the list, or specify a name using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
},
{
displayName: 'Search Term',
name: 'searchTerm',
type: 'string',
required: true,
default: '',
description: 'What to look for?',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Case Insensitive Search',
name: 'insensitive',
type: 'boolean',
default: false,
description:
'Whether the search ignores case sensitivity (true). Otherwise, it distinguishes between uppercase and lowercase characters.',
},
{
displayName: 'Activate Wildcard Search',
name: 'wildcard',
type: 'boolean',
default: true,
description:
'Whether the search only results perfect matches (true). Otherwise, it finds a row even if the search value is part of a string (false).',
},
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
default: true,
description:
'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Return Column Names',
name: 'convert',
type: 'boolean',
default: true,
description: 'Whether to return the column keys (false) or the column names (true)',
},
],
},
];
const displayOptions = {
show: {
resource: ['row'],
operation: ['search'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const tableName = this.getNodeParameter('tableName', index) as string;
const searchColumn = this.getNodeParameter('searchColumn', index) as string;
const searchTerm = this.getNodeParameter('searchTerm', index) as string | number;
let searchTermString = String(searchTerm);
const options = this.getNodeParameter('options', index) as IDataObject;
// get collaborators
const collaborators = await getBaseCollaborators.call(this);
// this is the base query. The WHERE has to be finalized...
let sqlQuery = `SELECT * FROM \`${tableName}\` WHERE \`${searchColumn}\``;
if (options.insensitive) {
searchTermString = searchTermString.toLowerCase();
sqlQuery = `SELECT * FROM \`${tableName}\` WHERE lower(\`${searchColumn}\`)`;
}
const wildcard = options.wildcard ?? true;
if (wildcard) sqlQuery = sqlQuery + ' LIKE "%' + searchTermString + '%"';
else if (!wildcard) sqlQuery = sqlQuery + ' = "' + searchTermString + '"';
const sqlResult = (await seaTableApiRequest.call(
this,
{},
'POST',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql',
{
sql: sqlQuery,
convert_keys: options.convert ?? true,
},
)) as IRowResponse;
const metadata = sqlResult.metadata as IDtableMetadataColumn[];
const rows = sqlResult.results;
// hide columns like button
rows.map((row) => enrichColumns(row, metadata, collaborators));
// remove columns starting with _;
if (options.simple) {
rows.map((row) => simplify_new(row));
}
return this.helpers.returnJsonArray(rows as IDataObject[]);
}

View File

@@ -0,0 +1,45 @@
import type { INodeProperties } from 'n8n-workflow';
export const sharedProperties: INodeProperties[] = [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Table Name',
name: 'tableName',
type: 'options',
placeholder: 'Select a table',
required: true,
typeOptions: {
loadOptionsMethod: 'getTableNames',
},
default: '',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
displayOptions: {
show: {
resource: ['row'],
},
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Row ID',
name: 'rowId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
required: true,
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getRowIds',
},
default: '',
displayOptions: {
show: {
resource: ['row'],
},
hide: {
operation: ['create', 'list', 'search'],
},
},
},
];

View File

@@ -0,0 +1,24 @@
import type { IDataObject, INodeExecutionData, IExecuteFunctions } from 'n8n-workflow';
import { seaTableApiRequest } from '../../GenericFunctions';
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const tableName = this.getNodeParameter('tableName', index) as string;
const rowId = this.getNodeParameter('rowId', index) as string;
const responseData = await seaTableApiRequest.call(
this,
{},
'PUT',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/unlock-rows/',
{
table_name: tableName,
row_ids: [rowId],
},
);
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}

View File

@@ -0,0 +1,173 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeProperties,
type IExecuteFunctions,
updateDisplayOptions,
} from 'n8n-workflow';
import {
seaTableApiRequest,
getTableColumns,
split,
rowExport,
updateAble,
splitStringColumnsToArrays,
} from '../../GenericFunctions';
import type { TColumnsUiValues, TColumnValue } from '../../types';
import type { IRowObject } from '../Interfaces';
export const properties: INodeProperties[] = [
{
displayName: 'Data to Send',
name: 'fieldsToSend',
type: 'options',
options: [
{
name: 'Auto-Map Input Data to Columns',
value: 'autoMapInputData',
description: 'Use when node input properties match destination column names',
},
{
name: 'Define Below for Each Column',
value: 'defineBelow',
description: 'Set the value for each destination column',
},
],
default: 'defineBelow',
description: 'Whether to insert the input data this node receives in the new row',
},
{
displayName: 'Inputs to Ignore',
name: 'inputsToIgnore',
type: 'string',
displayOptions: {
show: {
resource: ['row'],
operation: ['update'],
fieldsToSend: ['autoMapInputData'],
},
},
default: '',
description:
'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.',
placeholder: 'Enter properties...',
},
{
displayName: 'Columns to Send',
name: 'columnsUi',
placeholder: 'Add Column',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Column to Send',
multipleValues: true,
},
options: [
{
displayName: 'Column',
name: 'columnValues',
values: [
{
displayName: 'Column Name or ID',
name: 'columnName',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsDependsOn: ['tableName'],
loadOptionsMethod: 'getTableUpdateAbleColumns',
},
default: '',
},
{
displayName: 'Column Value',
name: 'columnValue',
type: 'string',
default: '',
},
],
},
],
displayOptions: {
show: {
resource: ['row'],
operation: ['update'],
fieldsToSend: ['defineBelow'],
},
},
default: {},
description:
'Add destination column with its value. Provide the value in this way:Date: YYYY-MM-DD or YYYY-MM-DD hh:mmDuration: time in secondsCheckbox: true, on or 1Multi-Select: comma-separated list.',
},
{
displayName: 'Hint: Link, files, images or digital signatures have to be added separately.',
name: 'notice',
type: 'notice',
default: '',
},
];
const displayOptions = {
show: {
resource: ['row'],
operation: ['update'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const tableName = this.getNodeParameter('tableName', index) as string;
const tableColumns = await getTableColumns.call(this, tableName);
const fieldsToSend = this.getNodeParameter('fieldsToSend', index) as
| 'defineBelow'
| 'autoMapInputData';
const rowId = this.getNodeParameter('rowId', index) as string;
let rowInput = {} as IRowObject;
// get rowInput, an object of key:value pairs like { Name: 'Promo Action 1', Status: "Draft" }.
if (fieldsToSend === 'autoMapInputData') {
const items = this.getInputData();
const incomingKeys = Object.keys(items[index].json);
const inputDataToIgnore = split(this.getNodeParameter('inputsToIgnore', index, '') as string);
for (const key of incomingKeys) {
if (inputDataToIgnore.includes(key)) continue;
rowInput[key] = items[index].json[key] as TColumnValue;
}
} else {
const columns = this.getNodeParameter('columnsUi.columnValues', index, []) as TColumnsUiValues;
for (const column of columns) {
rowInput[column.columnName] = column.columnValue;
}
}
// only keep key:value pairs for columns that are allowed to update.
rowInput = rowExport(rowInput, updateAble(tableColumns));
// string to array: multi-select and collaborators
rowInput = splitStringColumnsToArrays(rowInput, tableColumns);
const body = {
table_name: tableName,
updates: [
{
row_id: rowId,
row: rowInput,
},
],
} as IDataObject;
const responseData = await seaTableApiRequest.call(
this,
{},
'PUT',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/rows/',
body,
);
return this.helpers.returnJsonArray(responseData as IDataObject[]);
}

View File

@@ -0,0 +1,59 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow';
import * as asset from './asset';
import * as base from './base';
import * as link from './link';
import * as row from './row';
export const versionDescription: INodeTypeDescription = {
displayName: 'SeaTable',
name: 'seaTable',
icon: 'file:seaTable.svg',
group: ['output'],
version: 2,
subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}',
description: 'Consume the SeaTable API',
defaults: {
name: 'SeaTable',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [
{
name: 'seaTableApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Row',
value: 'row',
},
{
name: 'Base',
value: 'base',
},
{
name: 'Link',
value: 'link',
},
{
name: 'Asset',
value: 'asset',
},
],
default: 'row',
},
...row.descriptions,
...base.descriptions,
...link.descriptions,
...asset.descriptions,
],
};

View File

@@ -0,0 +1 @@
export * as loadOptions from './loadOptions';

View File

@@ -0,0 +1,280 @@
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import type { IRow } from '../actions/Interfaces';
import { getTableColumns, seaTableApiRequest, updateAble } from '../GenericFunctions';
export async function getTableNames(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const {
metadata: { tables },
} = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata',
);
for (const table of tables) {
returnData.push({
name: table.name,
value: table.name,
});
}
return returnData;
}
export async function getTableNameAndId(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const {
metadata: { tables },
} = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/metadata',
);
for (const table of tables) {
returnData.push({
name: table.name,
value: table.name + ':::' + table._id,
});
}
return returnData;
}
export async function getSearchableColumns(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const tableName = this.getCurrentNodeParameter('tableName') as string;
if (tableName) {
const columns = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns',
{},
{ table_name: tableName },
);
for (const col of columns.columns) {
if (
col.type === 'text' ||
col.type === 'long-text' ||
col.type === 'number' ||
col.type === 'single-select' ||
col.type === 'email' ||
col.type === 'url' ||
col.type === 'rate' ||
col.type === 'formula'
) {
returnData.push({
name: col.name,
value: col.name,
});
}
}
}
return returnData;
}
export async function getLinkColumns(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const table = this.getCurrentNodeParameter('tableName') as string;
const tableName = table.split(':::')[0];
const tableId = table.split(':::')[1];
if (tableName) {
const columns = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns',
{},
{ table_name: tableName },
);
for (const col of columns.columns) {
if (col.type === 'link') {
// make sure that the "other table id" is returned and not the same table id again.
const otid =
tableId !== col.data.other_table_id ? col.data.other_table_id : col.data.table_id;
returnData.push({
name: col.name,
value: col.name + ':::' + col.data.link_id + ':::' + otid,
});
}
}
}
return returnData;
}
export async function getLinkColumnsWithColumnKey(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const table = this.getCurrentNodeParameter('tableName') as string;
const tableName = table.split(':::')[0];
const tableId = table.split(':::')[1];
if (tableName) {
const columns = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns',
{},
{ table_name: tableName },
);
for (const col of columns.columns) {
if (col.type === 'link') {
// make sure that the "other table id" is returned and not the same table id again.
const otid =
tableId !== col.data.other_table_id ? col.data.other_table_id : col.data.table_id;
returnData.push({
name: col.name,
value: col.name + ':::' + col.data.link_id + ':::' + otid + ':::' + col.key,
});
}
}
}
return returnData;
}
export async function getAssetColumns(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const tableName = this.getCurrentNodeParameter('tableName') as string;
if (tableName) {
const columns = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns',
{},
{ table_name: tableName },
);
for (const col of columns.columns) {
if (col.type === 'image' || col.type === 'file') {
returnData.push({
name: col.name,
value: col.name + ':::' + col.type,
});
}
}
}
return returnData;
}
export async function getSignatureColumns(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const tableName = this.getCurrentNodeParameter('tableName') as string;
if (tableName) {
// only execute if table is selected
const columns = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/columns',
{},
{ table_name: tableName },
);
for (const col of columns.columns) {
if (col.type === 'digital-sign') {
returnData.push({
name: col.name,
value: col.name,
});
}
}
}
return returnData;
}
export async function getTableUpdateAbleColumns(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const tableName = this.getNodeParameter('tableName') as string;
let columns = await getTableColumns.call(this, tableName);
columns = updateAble(columns);
return columns
.filter((column) => column.editable)
.map((column) => ({ name: column.name, value: column.name }));
}
export async function getRowIds(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const table = this.getCurrentNodeParameter('tableName') as string;
const operation = this.getCurrentNodeParameter('operation') as string;
let tableName = table;
if (table.indexOf(':::') !== -1) {
tableName = table.split(':::')[0];
}
let lockQuery = '';
if (operation === 'lock') {
lockQuery = 'WHERE _locked is null';
}
if (operation === 'unlock') {
lockQuery = 'WHERE _locked = true';
}
const returnData: INodePropertyOptions[] = [];
if (tableName) {
const sqlResult = await seaTableApiRequest.call(
this,
{},
'POST',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/sql',
{
sql: `SELECT * FROM \`${tableName}\` ${lockQuery} LIMIT 1000`,
convert_keys: false,
},
);
const rows = sqlResult.results as IRow[];
for (const row of rows) {
returnData.push({
name: `${row['0000'] as string} (${row._id})`,
value: row._id,
});
}
}
return returnData;
}
export async function getTableViews(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const tableName = this.getCurrentNodeParameter('tableName') as string;
if (tableName) {
const { views } = await seaTableApiRequest.call(
this,
{},
'GET',
'/api-gateway/api/v2/dtables/{{dtable_uuid}}/views',
{},
{ table_name: tableName },
);
returnData.push({
name: '<Do not limit to a view>',
value: '',
});
for (const view of views) {
returnData.push({
name: view.name,
value: view.name,
});
}
}
return returnData;
}

View File

@@ -0,0 +1,100 @@
// ----------------------------------
// SeaTable
// ----------------------------------
export type TSeaTableServerVersion = '2.0.6';
export type TSeaTableServerEdition = 'enterprise edition';
// ----------------------------------
// dtable
// ----------------------------------
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import type {
IDtableMetadataColumn,
IDtableMetadataTable,
TDtableViewColumn,
} from './actions/Interfaces';
export type TColumnType =
| 'text'
| 'long-text'
| 'number'
| 'collaborator'
| 'date'
| 'duration'
| 'single-select'
| 'multiple-select'
| 'image'
| 'file'
| 'email'
| 'url'
| 'checkbox'
| 'rate'
| 'formula'
| 'link-formula'
| 'geolocation'
| 'link'
| 'creator'
| 'ctime'
| 'last-modifier'
| 'mtime'
| 'auto-number'
| 'button'
| 'digital-sign';
export type TInheritColumnKey =
| '_id'
| '_creator'
| '_ctime'
| '_last_modifier'
| '_mtime'
| '_seq'
| '_archived'
| '_locked'
| '_locked_by';
export type TColumnValue = undefined | boolean | number | string | string[] | null;
export type TColumnKey = TInheritColumnKey | string;
export type TDtableMetadataTables = readonly IDtableMetadataTable[];
export type TDtableMetadataColumns = IDtableMetadataColumn[];
export type TDtableViewColumns = readonly TDtableViewColumn[];
// ----------------------------------
// api
// ----------------------------------
export type TEndpointVariableName = 'access_token' | 'dtable_uuid' | 'server';
// Template Literal Types requires-ts-4.1.5 -- deferred
export type TMethod = 'GET' | 'POST';
type TEndpoint =
| '/api/v2.1/dtable/app-access-token/'
| '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/';
export type TEndpointExpr = TEndpoint;
export type TEndpointResolvedExpr =
TEndpoint; /* deferred: but already in use for header values, e.g. authentication */
export type TDateTimeFormat = 'YYYY-MM-DDTHH:mm:ss.SSSZ' /* moment.js */;
// ----------------------------------
// node
// ----------------------------------
export type TCredentials = ICredentialDataDecryptedObject | undefined;
export type TTriggerOperation = 'create' | 'update';
export type TOperation = 'append' | 'list' | 'metadata';
export type TLoadedResource = {
name: string;
};
export type TColumnsUiValues = Array<{
columnName: string;
columnValue: string;
}>;
export type APITypes = 'GET' | 'POST' | 'DELETE' | 'PUT';