first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,209 @@
import type { INodeProperties } from 'n8n-workflow';
import * as append from './append.operation';
import * as appendOrUpdate from './appendOrUpdate.operation';
import * as clear from './clear.operation';
import * as create from './create.operation';
import * as del from './delete.operation';
import * as read from './read.operation';
import * as remove from './remove.operation';
import * as update from './update.operation';
import { GOOGLE_DRIVE_FILE_URL_REGEX, GOOGLE_SHEETS_SHEET_URL_REGEX } from '../../../../constants';
export { append, appendOrUpdate, clear, create, del as delete, read, remove, update };
export const descriptions: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['sheet'],
},
},
options: [
{
name: 'Append or Update Row',
value: 'appendOrUpdate',
description: 'Append a new row or update an existing one (upsert)',
action: 'Append or update row in sheet',
},
{
name: 'Append Row',
value: 'append',
description: 'Create a new row in a sheet',
action: 'Append row in sheet',
},
{
name: 'Clear',
value: 'clear',
description: 'Delete all the contents or a part of a sheet',
action: 'Clear sheet',
},
{
name: 'Create',
value: 'create',
description: 'Create a new sheet',
action: 'Create sheet',
},
{
name: 'Delete',
value: 'remove',
description: 'Permanently delete a sheet',
action: 'Delete sheet',
},
{
name: 'Delete Rows or Columns',
value: 'delete',
description: 'Delete columns or rows from a sheet',
action: 'Delete rows or columns from sheet',
},
{
name: 'Get Row(s)',
value: 'read',
description: 'Retrieve one or more rows from a sheet',
action: 'Get row(s) in sheet',
},
{
name: 'Update Row',
value: 'update',
description: 'Update an existing row in a sheet',
action: 'Update row in sheet',
},
],
default: 'read',
},
{
displayName: 'Document',
name: 'documentId',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
builderHint: { message: "Default to mode: 'list' which is easier for users to set up" },
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'spreadSheetsSearch',
searchable: true,
},
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
extractValue: {
type: 'regex',
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
},
validation: [
{
type: 'regex',
properties: {
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
errorMessage: 'Not a valid Google Drive File URL',
},
},
],
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '[a-zA-Z0-9\\-_]{2,}',
errorMessage: 'Not a valid Google Drive File ID',
},
},
],
url: '=https://docs.google.com/spreadsheets/d/{{$value}}/edit',
},
],
displayOptions: {
show: {
resource: ['sheet'],
},
},
},
{
displayName: 'Sheet',
name: 'sheetName',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
// default: '', //empty string set to progresivly reveal fields
required: true,
builderHint: { message: "Default to mode: 'list' which is easier for users to set up" },
typeOptions: {
loadOptionsDependsOn: ['documentId.value'],
},
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'sheetsSearch',
searchable: false,
},
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
extractValue: {
type: 'regex',
regex: GOOGLE_SHEETS_SHEET_URL_REGEX,
},
validation: [
{
type: 'regex',
properties: {
regex: GOOGLE_SHEETS_SHEET_URL_REGEX,
errorMessage: 'Not a valid Sheet URL',
},
},
],
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '((gid=)?[0-9]{1,})',
errorMessage: 'Not a valid Sheet ID',
},
},
],
},
{
displayName: 'By Name',
name: 'name',
type: 'string',
placeholder: 'Sheet1',
},
],
displayOptions: {
show: {
resource: ['sheet'],
operation: ['append', 'appendOrUpdate', 'clear', 'delete', 'read', 'remove', 'update'],
},
},
},
...append.description,
...clear.description,
...create.description,
...del.description,
...read.description,
...update.description,
...appendOrUpdate.description,
];
@@ -0,0 +1,305 @@
import {
type IExecuteFunctions,
type IDataObject,
type INodeExecutionData,
NodeOperationError,
type ResourceMapperField,
} from 'n8n-workflow';
import { cellFormat, handlingExtraData, useAppendOption } from './commonDescription';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import type { SheetProperties, ValueInputOption } from '../../helpers/GoogleSheets.types';
import {
autoMapInputData,
cellFormatDefault,
checkForSchemaChanges,
mapFields,
untilSheetSelected,
} from '../../helpers/GoogleSheets.utils';
export const description: SheetProperties = [
{
displayName: 'Data Mode',
name: 'dataMode',
type: 'options',
options: [
{
name: 'Auto-Map Input Data to Columns',
value: 'autoMapInputData',
description: 'Use when node input properties match destination column names',
},
{
name: 'Map Each Column Below',
value: 'defineBelow',
description: 'Set the value for each destination column',
},
{
name: 'Nothing',
value: 'nothing',
description: 'Do not send anything',
},
],
displayOptions: {
show: {
resource: ['sheet'],
operation: ['append'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
default: 'defineBelow',
description: 'Whether to insert the input data this node receives in the new row',
},
{
displayName:
"In this mode, make sure the incoming data is named the same as the columns in your Sheet. (Use an 'Edit Fields' node before this node to change it if required.)",
name: 'autoMapNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['append'],
dataMode: ['autoMapInputData'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Fields to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Field to Send',
multipleValues: true,
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['append'],
dataMode: ['defineBelow'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
default: {},
options: [
{
displayName: 'Field',
name: 'fieldValues',
values: [
{
displayName: 'Field Name or ID',
name: 'fieldId',
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: ['sheetName.value'],
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
},
default: '',
},
{
displayName: 'Field Value',
name: 'fieldValue',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Columns',
name: 'columns',
type: 'resourceMapper',
noDataExpression: true,
default: {
mappingMode: 'defineBelow',
value: null,
},
required: true,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
resourceMapperMethod: 'getMappingColumns',
mode: 'add',
fieldWords: {
singular: 'column',
plural: 'columns',
},
addAllFields: true,
multiKeyMatch: false,
},
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['append'],
'@version': [{ _cnd: { gte: 4 } }],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['append'],
},
hide: {
...untilSheetSelected,
},
},
options: [
cellFormat,
{
displayName: 'Data Location on Sheet',
name: 'locationDefine',
type: 'fixedCollection',
placeholder: 'Select Range',
default: { values: {} },
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Header Row',
name: 'headerRow',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
description:
'Index of the row which contains the keys. Starts at 1. The incoming node data is matched to the keys for assignment. The matching is case sensitive.',
},
],
},
],
},
handlingExtraData,
{
...handlingExtraData,
displayOptions: { show: { '/columns.mappingMode': ['autoMapInputData'] } },
},
useAppendOption,
],
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
range: string,
sheetId: string,
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion;
let dataMode =
nodeVersion < 4
? (this.getNodeParameter('dataMode', 0) as string)
: (this.getNodeParameter('columns.mappingMode', 0) as string);
if (!items.length || dataMode === 'nothing') return [];
const options = this.getNodeParameter('options', 0, {});
const locationDefine = (options.locationDefine as IDataObject)?.values as IDataObject;
let keyRowIndex = 1;
if (locationDefine?.headerRow) {
keyRowIndex = locationDefine.headerRow as number;
}
const sheetData = await sheet.getData(range, 'FORMATTED_VALUE');
if (!sheetData?.length) {
dataMode = 'autoMapInputData';
}
if (nodeVersion >= 4.4 && dataMode !== 'autoMapInputData') {
//not possible to refresh columns when mode is autoMapInputData
if (sheetData?.[keyRowIndex - 1] === undefined) {
throw new NodeOperationError(
this.getNode(),
`Could not retrieve the column names from row ${keyRowIndex}`,
);
}
const schema = this.getNodeParameter('columns.schema', 0) as ResourceMapperField[];
checkForSchemaChanges(this.getNode(), sheetData[keyRowIndex - 1], schema);
}
let inputData: IDataObject[] = [];
if (dataMode === 'autoMapInputData') {
inputData = await autoMapInputData.call(this, range, sheet, items, options);
} else {
inputData = mapFields.call(this, items.length);
}
if (inputData.length === 0) {
return [];
}
const valueInputMode = (options.cellFormat as ValueInputOption) || cellFormatDefault(nodeVersion);
const useAppend = options.useAppend as boolean;
if (options.useAppend) {
await sheet.appendSheetData({
inputData,
range,
keyRowIndex,
valueInputMode,
useAppend,
});
} else {
//if no trailing empty row exists in the sheet update operation will fail
await sheet.appendEmptyRowsOrColumns(sheetId, 1, 0);
// if sheetData is undefined it means that the sheet was empty
// we did add row with column names in the first row (autoMapInputData)
// to account for that length has to be 1 and we append data in the next row
const lastRow = (sheetData ?? [{}]).length + 1;
await sheet.appendSheetData({
inputData,
range,
keyRowIndex,
valueInputMode,
lastRow,
});
}
if (nodeVersion < 4 || dataMode === 'autoMapInputData') {
return items.map((item, index) => {
item.pairedItem = { item: index };
return item;
});
} else {
const returnData: INodeExecutionData[] = [];
for (const [index, entry] of inputData.entries()) {
returnData.push({
json: entry,
pairedItem: { item: index },
});
}
return returnData;
}
}
@@ -0,0 +1,514 @@
import type {
IExecuteFunctions,
IDataObject,
INodeExecutionData,
ResourceMapperField,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import {
cellFormat,
handlingExtraData,
locationDefine,
useAppendOption,
} from './commonDescription';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import {
ROW_NUMBER,
type ISheetUpdateData,
type SheetProperties,
type ValueInputOption,
type ValueRenderOption,
} from '../../helpers/GoogleSheets.types';
import {
cellFormatDefault,
checkForSchemaChanges,
untilSheetSelected,
} from '../../helpers/GoogleSheets.utils';
export const description: SheetProperties = [
{
displayName: 'Data Mode',
name: 'dataMode',
type: 'options',
options: [
{
name: 'Auto-Map Input Data to Columns',
value: 'autoMapInputData',
description: 'Use when node input properties match destination column names',
},
{
name: 'Map Each Column Below',
value: 'defineBelow',
description: 'Set the value for each destination column',
},
{
name: 'Nothing',
value: 'nothing',
description: 'Do not send anything',
},
],
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
default: 'defineBelow',
description: 'Whether to insert the input data this node receives in the new row',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Column to match on',
name: 'columnToMatchOn',
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: ['sheetName.value'],
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
},
default: '',
hint: "Used to find the correct row to update. Doesn't get changed.",
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Value of Column to Match On',
name: 'valueToMatchOn',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
dataMode: ['defineBelow'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Values to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
dataMode: ['defineBelow'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
default: {},
options: [
{
displayName: 'Field',
name: 'values',
values: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Column',
name: 'column',
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: ['sheetName.value', 'columnToMatchOn'],
loadOptionsMethod: 'getSheetHeaderRowAndAddColumn',
},
default: '',
},
{
displayName: 'Column Name',
name: 'columnName',
type: 'string',
default: '',
displayOptions: {
show: {
column: ['newColumn'],
},
},
},
{
displayName: 'Value',
name: 'fieldValue',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Columns',
name: 'columns',
type: 'resourceMapper',
noDataExpression: true,
default: {
mappingMode: 'defineBelow',
value: null,
},
required: true,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
resourceMapperMethod: 'getMappingColumns',
mode: 'upsert',
fieldWords: {
singular: 'column',
plural: 'columns',
},
addAllFields: true,
multiKeyMatch: false,
allowEmptyValues: true,
},
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
'@version': [{ _cnd: { gte: 4.7 } }],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Columns',
name: 'columns',
type: 'resourceMapper',
noDataExpression: true,
default: {
mappingMode: 'defineBelow',
value: null,
},
required: true,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
resourceMapperMethod: 'getMappingColumns',
mode: 'upsert',
fieldWords: {
singular: 'column',
plural: 'columns',
},
addAllFields: true,
multiKeyMatch: false,
},
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
'@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
},
hide: {
...untilSheetSelected,
},
},
options: [
cellFormat,
locationDefine,
handlingExtraData,
{
...handlingExtraData,
displayOptions: { show: { '/columns.mappingMode': ['autoMapInputData'] } },
},
useAppendOption,
],
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
sheetId: string,
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion;
const range = `${sheetName}!A:Z`;
const valueInputMode = this.getNodeParameter(
'options.cellFormat',
0,
cellFormatDefault(nodeVersion),
) as ValueInputOption;
const options = this.getNodeParameter('options', 0, {});
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
const locationDefineOption = (options.locationDefine as IDataObject)?.values as IDataObject;
let keyRowIndex = 0;
let dataStartRowIndex = 1;
if (locationDefineOption) {
if (locationDefineOption.headerRow) {
keyRowIndex = parseInt(locationDefineOption.headerRow as string, 10) - 1;
}
if (locationDefineOption.firstDataRow) {
dataStartRowIndex = parseInt(locationDefineOption.firstDataRow as string, 10) - 1;
}
}
let dataMode =
nodeVersion < 4
? (this.getNodeParameter('dataMode', 0) as string)
: (this.getNodeParameter('columns.mappingMode', 0) as string);
let columnNames: string[] = [];
const sheetData = (await sheet.getData(sheetName, 'FORMATTED_VALUE')) ?? [];
if (!sheetData[keyRowIndex] && dataMode !== 'autoMapInputData') {
if (!sheetData.length) {
dataMode = 'autoMapInputData';
} else {
throw new NodeOperationError(
this.getNode(),
`Could not retrieve the column names from row ${keyRowIndex + 1}`,
);
}
}
columnNames = sheetData[keyRowIndex] ?? [];
if (nodeVersion >= 4.4) {
const schema = this.getNodeParameter('columns.schema', 0) as ResourceMapperField[];
checkForSchemaChanges(this.getNode(), columnNames, schema);
}
const newColumns = new Set<string>();
const columnsToMatchOn: string[] =
nodeVersion < 4
? [this.getNodeParameter('columnToMatchOn', 0) as string]
: (this.getNodeParameter('columns.matchingColumns', 0) as string[]);
// TODO: Add support for multiple columns to match on in the next overhaul
const keyIndex = columnNames.indexOf(columnsToMatchOn[0]);
const columnValuesList = await sheet.getColumnValues({
range,
keyIndex,
dataStartRowIndex,
valueRenderMode,
sheetData,
});
const updateData: ISheetUpdateData[] = [];
const appendData: IDataObject[] = [];
const errorOnUnexpectedColumn = (key: string, i: number) => {
if (!columnNames.includes(key)) {
throw new NodeOperationError(this.getNode(), 'Unexpected fields in node input', {
itemIndex: i,
description: `The input field '${key}' doesn't match any column in the Sheet. You can ignore this by changing the 'Handling extra data' field, which you can find under 'Options'.`,
});
}
};
const addNewColumn = (key: string) => {
if (!columnNames.includes(key) && key !== ROW_NUMBER) {
newColumns.add(key);
}
};
const mappedValues: IDataObject[] = [];
for (let i = 0; i < items.length; i++) {
if (dataMode === 'nothing') continue;
const inputData: IDataObject[] = [];
if (dataMode === 'autoMapInputData') {
const handlingExtraDataOption = (options.handlingExtraData as string) || 'insertInNewColumn';
if (handlingExtraDataOption === 'ignoreIt') {
inputData.push(items[i].json);
}
if (handlingExtraDataOption === 'error') {
Object.keys(items[i].json).forEach((key) => errorOnUnexpectedColumn(key, i));
inputData.push(items[i].json);
}
if (handlingExtraDataOption === 'insertInNewColumn') {
Object.keys(items[i].json).forEach(addNewColumn);
inputData.push(items[i].json);
}
} else {
const valueToMatchOn =
nodeVersion < 4
? (this.getNodeParameter('valueToMatchOn', i, '') as string)
: (this.getNodeParameter(`columns.value[${columnsToMatchOn[0]}]`, i, '') as string);
if (valueToMatchOn === '') {
throw new NodeOperationError(
this.getNode(),
"The 'Column to Match On' parameter is required",
{
itemIndex: i,
},
);
}
if (nodeVersion < 4) {
const valuesToSend = this.getNodeParameter('fieldsUi.values', i, []) as IDataObject[];
if (!valuesToSend?.length) {
throw new NodeOperationError(
this.getNode(),
"At least one value has to be added under 'Values to Send'",
);
}
const fields = valuesToSend.reduce((acc, entry) => {
if (entry.column === 'newColumn') {
const columnName = entry.columnName as string;
if (!columnNames.includes(columnName)) {
newColumns.add(columnName);
}
acc[columnName] = entry.fieldValue as string;
} else {
acc[entry.column as string] = entry.fieldValue as string;
}
return acc;
}, {} as IDataObject);
fields[columnsToMatchOn[0]] = valueToMatchOn;
inputData.push(fields);
} else {
const mappingValues = this.getNodeParameter('columns.value', i) as IDataObject;
if (Object.keys(mappingValues).length === 0) {
throw new NodeOperationError(
this.getNode(),
"At least one value has to be added under 'Values to Send'",
);
}
// Setting empty values to empty string so that they are not ignored by the API
Object.keys(mappingValues).forEach((key) => {
if (mappingValues[key] === undefined || mappingValues[key] === null) {
mappingValues[key] = '';
}
});
inputData.push(mappingValues);
mappedValues.push(mappingValues);
}
}
if (newColumns.size) {
const newColumnNames = columnNames.concat([...newColumns]);
await sheet.updateRows(
sheetName,
[newColumnNames],
(options.cellFormat as ValueInputOption) || cellFormatDefault(nodeVersion),
keyRowIndex + 1,
);
columnNames = newColumnNames;
sheetData[keyRowIndex] = newColumnNames;
newColumns.clear();
}
const indexKey = columnsToMatchOn[0];
const preparedData = await sheet.prepareDataForUpdateOrUpsert({
inputData,
indexKey,
range,
keyRowIndex,
dataStartRowIndex,
valueRenderMode,
upsert: true,
columnNamesList: [columnNames.concat([...newColumns])],
columnValuesList,
});
updateData.push(...preparedData.updateData);
appendData.push(...preparedData.appendData);
}
const columnNamesList = [columnNames.concat([...newColumns])];
if (updateData.length) {
await sheet.batchUpdate(updateData, valueInputMode);
}
if (appendData.length) {
const lastRow = sheetData.length + 1;
const useAppend = options.useAppend as boolean;
if (options.useAppend) {
await sheet.appendSheetData({
inputData: appendData,
range,
keyRowIndex: keyRowIndex + 1,
valueInputMode,
columnNamesList,
lastRow,
useAppend,
});
} else {
await sheet.appendEmptyRowsOrColumns(sheetId, 1, 0);
await sheet.appendSheetData({
inputData: appendData,
range,
keyRowIndex: keyRowIndex + 1,
valueInputMode,
columnNamesList,
lastRow,
});
}
}
if (nodeVersion < 4 || dataMode === 'autoMapInputData') {
return items.map((item, index) => {
item.pairedItem = { item: index };
return item;
});
} else {
const returnData: INodeExecutionData[] = [];
for (const [index, entry] of mappedValues.entries()) {
returnData.push({
json: entry,
pairedItem: { item: index },
});
}
return returnData;
}
}
@@ -0,0 +1,210 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
import {
getColumnName,
getColumnNumber,
untilSheetSelected,
} from '../../helpers/GoogleSheets.utils';
export const description: SheetProperties = [
{
displayName: 'Clear',
name: 'clear',
type: 'options',
options: [
{
name: 'Whole Sheet',
value: 'wholeSheet',
},
{
name: 'Specific Rows',
value: 'specificRows',
},
{
name: 'Specific Columns',
value: 'specificColumns',
},
{
name: 'Specific Range',
value: 'specificRange',
},
],
displayOptions: {
show: {
resource: ['sheet'],
operation: ['clear'],
},
hide: {
...untilSheetSelected,
},
},
default: 'wholeSheet',
description: 'What to clear',
},
{
displayName: 'Keep First Row',
name: 'keepFirstRow',
type: 'boolean',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['clear'],
clear: ['wholeSheet'],
},
hide: {
...untilSheetSelected,
},
},
default: false,
},
{
displayName: 'Start Row Number',
name: 'startIndex',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
description: 'The row number to delete from, The first row is 1',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['clear'],
clear: ['specificRows'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Number of Rows to Delete',
name: 'rowsToDelete',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
displayOptions: {
show: {
resource: ['sheet'],
operation: ['clear'],
clear: ['specificRows'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Start Column',
name: 'startIndex',
type: 'string',
default: 'A',
description: 'The column to delete',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['clear'],
clear: ['specificColumns'],
},
hide: {
...untilSheetSelected,
},
},
},
{
// Could this be better as "end column"?
displayName: 'Number of Columns to Delete',
name: 'columnsToDelete',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
displayOptions: {
show: {
resource: ['sheet'],
operation: ['clear'],
clear: ['specificColumns'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Range',
name: 'range',
type: 'string',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['clear'],
clear: ['specificRange'],
},
hide: {
...untilSheetSelected,
},
},
default: 'A:F',
required: true,
description:
'The table range to read from or to append data to. See the Google <a href="https://developers.google.com/sheets/api/guides/values#writing">documentation</a> for the details. If it contains multiple sheets it can also be added like this: "MySheet!A:F"',
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
const clearType = this.getNodeParameter('clear', i) as string;
const keepFirstRow = this.getNodeParameter('keepFirstRow', i, false) as boolean;
let range = '';
if (clearType === 'specificRows') {
const startIndex = this.getNodeParameter('startIndex', i) as number;
const rowsToDelete = this.getNodeParameter('rowsToDelete', i) as number;
const endIndex = rowsToDelete === 1 ? startIndex : startIndex + rowsToDelete - 1;
range = `${sheetName}!${startIndex}:${endIndex}`;
}
if (clearType === 'specificColumns') {
const startIndex = this.getNodeParameter('startIndex', i) as string;
const columnsToDelete = this.getNodeParameter('columnsToDelete', i) as number;
const columnNumber = getColumnNumber(startIndex);
const endIndex = columnsToDelete === 1 ? columnNumber : columnNumber + columnsToDelete - 1;
range = `${sheetName}!${startIndex}:${getColumnName(endIndex)}`;
}
if (clearType === 'specificRange') {
const rangeField = this.getNodeParameter('range', i) as string;
const region = rangeField.includes('!') ? rangeField.split('!')[1] || '' : rangeField;
range = `${sheetName}!${region}`;
}
if (clearType === 'wholeSheet') {
range = sheetName;
}
if (keepFirstRow) {
const firstRow = await sheet.getData(`${range}!1:1`, 'FORMATTED_VALUE');
await sheet.clearData(range);
await sheet.updateRows(range, firstRow as string[][], 'RAW', 1);
} else {
await sheet.clearData(range);
}
}
return items;
}
@@ -0,0 +1,270 @@
import type { INodeProperties } from 'n8n-workflow';
export const dataLocationOnSheet: INodeProperties = {
displayName: 'Data Location on Sheet',
name: 'dataLocationOnSheet',
type: 'fixedCollection',
placeholder: 'Select Range',
default: { values: { rangeDefinition: 'detectAutomatically' } },
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Range Definition',
name: 'rangeDefinition',
type: 'options',
options: [
{
name: 'Detect Automatically',
value: 'detectAutomatically',
description: 'Automatically detect the data range',
},
{
name: 'Specify Range (A1 Notation)',
value: 'specifyRangeA1',
description: 'Manually specify the data range',
},
{
name: 'Specify Range (Rows)',
value: 'specifyRange',
description: 'Manually specify the data range',
},
],
default: '',
},
{
displayName: 'Read Rows Until',
name: 'readRowsUntil',
type: 'options',
default: 'lastRowInSheet',
options: [
{
name: 'First Empty Row',
value: 'firstEmptyRow',
},
{
name: 'Last Row In Sheet',
value: 'lastRowInSheet',
},
],
displayOptions: {
show: {
rangeDefinition: ['detectAutomatically'],
},
},
},
{
displayName: 'Header Row',
name: 'headerRow',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
description: "Index is relative to the set 'Range', first row index is 1",
hint: 'Index of the row which contains the column names',
displayOptions: {
show: {
rangeDefinition: ['specifyRange'],
},
},
},
{
displayName: 'First Data Row',
name: 'firstDataRow',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 2,
description: "Index is relative to the set 'Range', first row index is 1",
hint: 'Index of first row which contains the actual data',
displayOptions: {
show: {
rangeDefinition: ['specifyRange'],
},
},
},
{
displayName: 'Range',
name: 'range',
type: 'string',
default: '',
placeholder: 'A:Z',
description:
'The table range to read from or to append data to. See the Google <a href="https://developers.google.com/sheets/api/guides/values#writing">documentation</a> for the details.',
hint: 'You can specify both the rows and the columns, e.g. C4:E7',
displayOptions: {
show: {
rangeDefinition: ['specifyRangeA1'],
},
},
},
],
},
],
};
export const locationDefine: INodeProperties = {
displayName: 'Data Location on Sheet',
name: 'locationDefine',
type: 'fixedCollection',
placeholder: 'Select Range',
default: { values: {} },
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Header Row',
name: 'headerRow',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
description: "Index is relative to the set 'Range', first row index is 1",
hint: 'Index of the row which contains the column names',
},
{
displayName: 'First Data Row',
name: 'firstDataRow',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 2,
description: "Index is relative to the set 'Range', first row index is 1",
hint: 'Index of first row which contains the actual data',
},
],
},
],
};
export const outputFormatting: INodeProperties = {
displayName: 'Output Formatting',
name: 'outputFormatting',
type: 'fixedCollection',
placeholder: 'Add Formatting',
default: { values: { general: 'UNFORMATTED_VALUE', date: 'FORMATTED_STRING' } },
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'General Formatting',
name: 'general',
type: 'options',
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Values (unformatted)',
value: 'UNFORMATTED_VALUE',
description:
'Numbers stay as numbers, but any currency signs or special formatting is lost',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Values (formatted)',
value: 'FORMATTED_VALUE',
description:
'Numbers are turned to text, and displayed as in Google Sheets (e.g. with commas or currency signs)',
},
{
name: 'Formulas',
value: 'FORMULA',
},
],
default: '',
description: 'Determines how values should be rendered in the output',
},
{
displayName: 'Date Formatting',
name: 'date',
type: 'options',
default: '',
options: [
{
name: 'Formatted Text',
value: 'FORMATTED_STRING',
description: "As displayed in Google Sheets, e.g. '01/01/2022'",
},
{
name: 'Serial Number',
value: 'SERIAL_NUMBER',
description: 'A number representing the number of days since Dec 30, 1899',
},
],
},
],
},
],
};
export const cellFormat: INodeProperties = {
displayName: 'Cell Format',
name: 'cellFormat',
type: 'options',
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Let Google Sheets format',
value: 'USER_ENTERED',
description: 'Cells are styled as if you typed the values into Google Sheets directly',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Let n8n format',
value: 'RAW',
description: 'Cells have the same types as the input data',
},
],
default: 'USER_ENTERED',
description: 'Determines how data should be interpreted',
};
export const handlingExtraData: INodeProperties = {
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Handling extra fields in input',
name: 'handlingExtraData',
type: 'options',
options: [
{
name: 'Insert in New Column(s)',
value: 'insertInNewColumn',
description: 'Create a new column for extra data',
},
{
name: 'Ignore Them',
value: 'ignoreIt',
description: 'Ignore extra data',
},
{
name: 'Error',
value: 'error',
description: 'Throw an error',
},
],
displayOptions: {
show: {
'/dataMode': ['autoMapInputData'],
},
},
default: 'insertInNewColumn',
description: "What do to with fields that don't match any columns in the Google Sheet",
};
export const useAppendOption: INodeProperties = {
displayName: 'Minimise API Calls',
name: 'useAppend',
type: 'boolean',
default: false,
hint: 'Use if your sheet has no gaps between rows or columns',
description:
'Whether to use append instead of update(default), this is more efficient but in some cases data might be misaligned',
};
@@ -0,0 +1,133 @@
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
import { wrapData } from '../../../../../../utils/utilities';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
import { getExistingSheetNames, hexToRgb } from '../../helpers/GoogleSheets.utils';
import { apiRequest } from '../../transport';
export const description: SheetProperties = [
{
displayName: 'Title',
name: 'title',
type: 'string',
required: true,
default: 'n8n-sheet',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['create'],
},
},
description: 'The name of the sheet',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['create'],
},
},
options: [
{
displayName: 'Hidden',
name: 'hidden',
type: 'boolean',
default: false,
description: "Whether the sheet is hidden in the UI, false if it's visible",
},
{
displayName: 'Right To Left',
name: 'rightToLeft',
type: 'boolean',
default: false,
description: 'Whether the sheet is an RTL sheet instead of an LTR sheet',
},
{
displayName: 'Sheet ID',
name: 'sheetId',
type: 'number',
default: 0,
description:
'The ID of the sheet. Must be non-negative. This field cannot be changed once set.',
},
{
displayName: 'Sheet Index',
name: 'index',
type: 'number',
default: 0,
description: 'The index of the sheet within the spreadsheet',
},
{
displayName: 'Tab Color',
name: 'tabColor',
type: 'color',
default: '0aa55c',
description: 'The color of the tab in the UI',
},
],
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
let responseData;
const returnData: INodeExecutionData[] = [];
const items = this.getInputData();
const existingSheetNames = await getExistingSheetNames(sheet);
for (let i = 0; i < items.length; i++) {
const sheetTitle = this.getNodeParameter('title', i, {}) as string;
if (existingSheetNames.includes(sheetTitle)) {
continue;
}
const options = this.getNodeParameter('options', i, {});
const properties = { ...options };
properties.title = sheetTitle;
if (options.tabColor) {
const { red, green, blue } = hexToRgb(options.tabColor as string)!;
properties.tabColor = { red: red / 255, green: green / 255, blue: blue / 255 };
}
const requests = [
{
addSheet: {
properties,
},
},
];
responseData = await apiRequest.call(
this,
'POST',
`/v4/spreadsheets/${sheetName}:batchUpdate`,
{ requests },
);
// simplify response
Object.assign(responseData, responseData.replies[0].addSheet.properties);
delete responseData.replies;
existingSheetNames.push(sheetTitle);
const executionData = this.helpers.constructExecutionMetaData(
wrapData(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
return returnData;
}
@@ -0,0 +1,175 @@
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
import { generatePairedItemData, wrapData } from '../../../../../../utils/utilities';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
import { getColumnNumber, untilSheetSelected } from '../../helpers/GoogleSheets.utils';
export const description: SheetProperties = [
{
displayName: 'To Delete',
name: 'toDelete',
type: 'options',
options: [
{
name: 'Rows',
value: 'rows',
description: 'Rows to delete',
},
{
name: 'Columns',
value: 'columns',
description: 'Columns to delete',
},
],
displayOptions: {
show: {
resource: ['sheet'],
operation: ['delete'],
},
hide: {
...untilSheetSelected,
},
},
default: 'rows',
description: 'What to delete',
},
{
displayName: 'Start Row Number',
name: 'startIndex',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 2,
description: 'The row number to delete from, The first row is 2',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['delete'],
toDelete: ['rows'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Number of Rows to Delete',
name: 'numberToDelete',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
displayOptions: {
show: {
resource: ['sheet'],
operation: ['delete'],
toDelete: ['rows'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Start Column',
name: 'startIndex',
type: 'string',
default: 'A',
description: 'The column to delete',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['delete'],
toDelete: ['columns'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Number of Columns to Delete',
name: 'numberToDelete',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
displayOptions: {
show: {
resource: ['sheet'],
operation: ['delete'],
toDelete: ['columns'],
},
hide: {
...untilSheetSelected,
},
},
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
const requests: IDataObject[] = [];
let startIndex, endIndex, numberToDelete;
const deleteType = this.getNodeParameter('toDelete', i) as string;
if (deleteType === 'rows') {
startIndex = this.getNodeParameter('startIndex', i) as number;
// We start from 1 now...
startIndex--;
numberToDelete = this.getNodeParameter('numberToDelete', i) as number;
if (numberToDelete === 1) {
endIndex = startIndex + 1;
} else {
endIndex = startIndex + numberToDelete;
}
requests.push({
deleteDimension: {
range: {
sheetId: sheetName,
dimension: 'ROWS',
startIndex,
endIndex,
},
},
});
} else if (deleteType === 'columns') {
startIndex = this.getNodeParameter('startIndex', i) as string;
numberToDelete = this.getNodeParameter('numberToDelete', i) as number;
startIndex = getColumnNumber(startIndex) - 1;
if (numberToDelete === 1) {
endIndex = startIndex + 1;
} else {
endIndex = startIndex + numberToDelete;
}
requests.push({
deleteDimension: {
range: {
sheetId: sheetName,
dimension: 'COLUMNS',
startIndex,
endIndex,
},
},
});
}
await sheet.spreadsheetBatchUpdate(requests);
}
const itemData = generatePairedItemData(this.getInputData().length);
const returnData = this.helpers.constructExecutionMetaData(wrapData({ success: true }), {
itemData,
});
return returnData;
}
@@ -0,0 +1,198 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { dataLocationOnSheet, outputFormatting } from './commonDescription';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
import { untilSheetSelected } from '../../helpers/GoogleSheets.utils';
import { readSheet } from '../utils/readOperation';
const combineFiltersOptions: INodeProperties = {
displayName: 'Combine Filters',
name: 'combineFilters',
type: 'options',
description:
'How to combine the conditions defined in "Filters": AND requires all conditions to be true, OR requires at least one condition to be true',
options: [
{
name: 'AND',
value: 'AND',
description: 'Only rows that meet all the conditions are selected',
},
{
name: 'OR',
value: 'OR',
description: 'Rows that meet at least one condition are selected',
},
],
default: 'AND',
};
export const readFilter: INodeProperties = {
displayName: 'Filters',
name: 'filtersUI',
placeholder: 'Add Filter',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Filter',
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Filter',
name: 'values',
values: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Column',
name: 'lookupColumn',
type: 'options',
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
loadOptionsMethod: 'getSheetHeaderRowWithGeneratedColumnNames',
},
default: '',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
},
{
displayName: 'Value',
name: 'lookupValue',
type: 'string',
default: '',
hint: 'The column must have this value to be matched',
},
],
},
],
};
export const description: SheetProperties = [
{
...readFilter,
displayOptions: {
show: {
resource: ['sheet'],
operation: ['read'],
},
hide: {
...untilSheetSelected,
},
},
},
{
...combineFiltersOptions,
default: 'OR',
displayOptions: {
show: {
'@version': [{ _cnd: { lt: 4.3 } }],
resource: ['sheet'],
operation: ['read'],
},
hide: {
...untilSheetSelected,
},
},
},
{
...combineFiltersOptions,
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 4.3 } }],
resource: ['sheet'],
operation: ['read'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['read'],
},
hide: {
...untilSheetSelected,
},
},
options: [
dataLocationOnSheet,
outputFormatting,
{
displayName: 'Return only First Matching Row',
name: 'returnFirstMatch',
type: 'boolean',
default: false,
description:
'Whether to select the first row of the sheet or the first matching row (if filters are set)',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 4.5 } }],
},
},
},
{
displayName: 'When Filter Has Multiple Matches',
name: 'returnAllMatches',
type: 'options',
default: 'returnFirstMatch',
options: [
{
name: 'Return First Match',
value: 'returnFirstMatch',
description: 'Return only the first match',
},
{
name: 'Return All Matches',
value: 'returnAllMatches',
description: 'Return all values that match',
},
],
description:
'By default only the first result gets returned, Set to "Return All Matches" to get multiple matches',
displayOptions: {
show: {
'@version': [{ _cnd: { lt: 4.5 } }],
},
},
},
],
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion;
let length = 1;
if (nodeVersion > 4.1) {
length = items.length;
}
let returnData: INodeExecutionData[] = [];
for (let itemIndex = 0; itemIndex < length; itemIndex++) {
returnData = await readSheet.call(
this,
sheet,
sheetName,
itemIndex,
returnData,
nodeVersion,
items,
);
}
return returnData;
}
@@ -0,0 +1,41 @@
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
import { wrapData } from '../../../../../../utils/utilities';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import { apiRequest } from '../../transport';
export async function execute(
this: IExecuteFunctions,
_sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
const [spreadsheetId, sheetWithinDocument] = sheetName.split('||');
const requests = [
{
deleteSheet: {
sheetId: sheetWithinDocument,
},
},
];
const responseData = await apiRequest.call(
this,
'POST',
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
{ requests },
);
delete responseData.replies;
const executionData = this.helpers.constructExecutionMetaData(
wrapData(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
return returnData;
}
@@ -0,0 +1,490 @@
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError, UserError } from 'n8n-workflow';
import { cellFormat, handlingExtraData, locationDefine } from './commonDescription';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import {
ROW_NUMBER,
type ISheetUpdateData,
type SheetProperties,
type ValueInputOption,
type ValueRenderOption,
} from '../../helpers/GoogleSheets.types';
import { cellFormatDefault, untilSheetSelected } from '../../helpers/GoogleSheets.utils';
export const description: SheetProperties = [
{
displayName: 'Data Mode',
name: 'dataMode',
type: 'options',
options: [
{
name: 'Auto-Map Input Data to Columns',
value: 'autoMapInputData',
description: 'Use when node input properties match destination column names',
},
{
name: 'Map Each Column Below',
value: 'defineBelow',
description: 'Set the value for each destination column',
},
{
name: 'Nothing',
value: 'nothing',
description: 'Do not send anything',
},
],
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
default: 'defineBelow',
description: 'Whether to insert the input data this node receives in the new row',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Column to match on',
name: 'columnToMatchOn',
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: ['sheetName.value'],
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
},
default: '',
hint: "Used to find the correct row to update. Doesn't get changed.",
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Value of Column to Match On',
name: 'valueToMatchOn',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
dataMode: ['defineBelow'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Values to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
dataMode: ['defineBelow'],
'@version': [3],
},
hide: {
...untilSheetSelected,
},
},
default: {},
options: [
{
displayName: 'Field',
name: 'values',
values: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Column',
name: 'column',
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: ['sheetName.value', 'columnToMatchOn'],
loadOptionsMethod: 'getSheetHeaderRowAndAddColumn',
},
default: '',
},
{
displayName: 'Column Name',
name: 'columnName',
type: 'string',
default: '',
displayOptions: {
show: {
column: ['newColumn'],
},
},
},
{
displayName: 'Value',
name: 'fieldValue',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Columns',
name: 'columns',
type: 'resourceMapper',
noDataExpression: true,
default: {
mappingMode: 'defineBelow',
value: null,
},
required: true,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
resourceMapperMethod: 'getMappingColumns',
mode: 'update',
fieldWords: {
singular: 'column',
plural: 'columns',
},
addAllFields: true,
multiKeyMatch: false,
allowEmptyValues: true,
},
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
'@version': [{ _cnd: { gte: 4.7 } }],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Columns',
name: 'columns',
type: 'resourceMapper',
noDataExpression: true,
default: {
mappingMode: 'defineBelow',
value: null,
},
required: true,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
resourceMapperMethod: 'getMappingColumns',
mode: 'update',
fieldWords: {
singular: 'column',
plural: 'columns',
},
addAllFields: true,
multiKeyMatch: false,
},
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
'@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
},
hide: {
...untilSheetSelected,
},
},
options: [
cellFormat,
locationDefine,
handlingExtraData,
{
...handlingExtraData,
displayOptions: { show: { '/columns.mappingMode': ['autoMapInputData'] } },
},
],
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion;
const range = `${sheetName}!A:Z`;
const valueInputMode = this.getNodeParameter(
'options.cellFormat',
0,
cellFormatDefault(nodeVersion),
) as ValueInputOption;
const options = this.getNodeParameter('options', 0, {});
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
const locationDefineOptions = (options.locationDefine as IDataObject)?.values as IDataObject;
let keyRowIndex = 0;
let dataStartRowIndex = 1;
if (locationDefineOptions) {
if (locationDefineOptions.headerRow) {
keyRowIndex = parseInt(locationDefineOptions.headerRow as string, 10) - 1;
}
if (locationDefineOptions.firstDataRow) {
dataStartRowIndex = parseInt(locationDefineOptions.firstDataRow as string, 10) - 1;
}
}
let columnNames: string[] = [];
const sheetData = await sheet.getData(sheetName, 'FORMATTED_VALUE');
if (sheetData?.[keyRowIndex] === undefined) {
throw new NodeOperationError(
this.getNode(),
`Could not retrieve the column names from row ${keyRowIndex + 1}`,
);
}
columnNames = sheetData[keyRowIndex];
const newColumns = new Set<string>();
const columnsToMatchOn: string[] =
nodeVersion < 4
? [this.getNodeParameter('columnToMatchOn', 0) as string]
: (this.getNodeParameter('columns.matchingColumns', 0) as string[]);
const dataMode =
nodeVersion < 4
? (this.getNodeParameter('dataMode', 0) as string)
: (this.getNodeParameter('columns.mappingMode', 0) as string);
// TODO: Add support for multiple columns to match on in the next overhaul
const keyIndex = columnNames.indexOf(columnsToMatchOn[0]);
//not used when updating row
const columnValuesList = await sheet.getColumnValues({
range,
keyIndex,
dataStartRowIndex,
valueRenderMode,
sheetData,
});
const updateData: ISheetUpdateData[] = [];
const mappedValues: IDataObject[] = [];
const errorOnUnexpectedColumn = (key: string, i: number) => {
if (!columnNames.includes(key)) {
throw new NodeOperationError(this.getNode(), 'Unexpected fields in node input', {
itemIndex: i,
description: `The input field '${key}' doesn't match any column in the Sheet. You can ignore this by changing the 'Handling extra data' field, which you can find under 'Options'.`,
});
}
};
const addNewColumn = (key: string) => {
if (!columnNames.includes(key) && key !== ROW_NUMBER) {
newColumns.add(key);
}
};
for (let i = 0; i < items.length; i++) {
if (dataMode === 'nothing') continue;
const inputData: IDataObject[] = [];
if (dataMode === 'autoMapInputData') {
const handlingExtraDataOption = (options.handlingExtraData as string) || 'insertInNewColumn';
if (handlingExtraDataOption === 'ignoreIt') {
inputData.push(items[i].json);
}
if (handlingExtraDataOption === 'error') {
Object.keys(items[i].json).forEach((key) => errorOnUnexpectedColumn(key, i));
inputData.push(items[i].json);
}
if (handlingExtraDataOption === 'insertInNewColumn') {
Object.keys(items[i].json).forEach(addNewColumn);
inputData.push(items[i].json);
}
} else {
const valueToMatchOn =
nodeVersion < 4
? (this.getNodeParameter('valueToMatchOn', i, '') as string)
: (this.getNodeParameter(`columns.value["${columnsToMatchOn[0]}"]`, i, '') as string);
if (valueToMatchOn === '') {
throw new NodeOperationError(
this.getNode(),
"The 'Column to Match On' parameter is required",
{
itemIndex: i,
},
);
}
if (nodeVersion < 4) {
const valuesToSend = this.getNodeParameter('fieldsUi.values', i, []) as IDataObject[];
if (!valuesToSend?.length) {
throw new NodeOperationError(
this.getNode(),
"At least one value has to be added under 'Values to Send'",
);
}
const fields = valuesToSend.reduce((acc, entry) => {
if (entry.column === 'newColumn') {
const columnName = entry.columnName as string;
if (!columnNames.includes(columnName)) {
newColumns.add(columnName);
}
acc[columnName] = entry.fieldValue as string;
} else {
acc[entry.column as string] = entry.fieldValue as string;
}
return acc;
}, {} as IDataObject);
fields[columnsToMatchOn[0]] = valueToMatchOn;
inputData.push(fields);
} else {
const mappingValues = this.getNodeParameter('columns.value', i) as IDataObject;
if (Object.keys(mappingValues).length === 0) {
throw new NodeOperationError(
this.getNode(),
"At least one value has to be added under 'Values to Send'",
);
}
// Setting empty values to empty string so that they are not ignored by the API
Object.keys(mappingValues).forEach((key) => {
// null and undefined values are mapped to undefined
if (key === 'row_number' && mappingValues[key] === undefined && nodeVersion >= 4.6) {
throw new UserError('row_number is null or undefined', {
description:
"Since it's being used to determine the row to update, it cannot be null or undefined",
});
}
// null and undefined values are mapped to undefined
if (mappingValues[key] === undefined) {
this.addExecutionHints({
message: 'Warning: The value of column to match is null or undefined',
location: 'outputPane',
});
}
if (mappingValues[key] === undefined || mappingValues[key] === null) {
mappingValues[key] = '';
}
});
inputData.push(mappingValues);
mappedValues.push(mappingValues);
}
}
if (newColumns.size) {
const newColumnNames = columnNames.concat([...newColumns]);
await sheet.updateRows(
sheetName,
[newColumnNames],
(options.cellFormat as ValueInputOption) || cellFormatDefault(nodeVersion),
keyRowIndex + 1,
);
columnNames = newColumnNames;
newColumns.clear();
}
let preparedData;
const columnNamesList = [columnNames.concat([...newColumns])];
if (columnsToMatchOn[0] === 'row_number') {
preparedData = sheet.prepareDataForUpdatingByRowNumber(inputData, range, columnNamesList);
} else {
const indexKey = columnsToMatchOn[0];
preparedData = await sheet.prepareDataForUpdateOrUpsert({
inputData,
indexKey,
range,
keyRowIndex,
dataStartRowIndex,
valueRenderMode,
columnNamesList,
columnValuesList,
});
}
updateData.push(...preparedData.updateData);
}
if (updateData.length) {
await sheet.batchUpdate(updateData, valueInputMode);
}
if (nodeVersion < 4 || dataMode === 'autoMapInputData') {
return items.map((item, index) => {
item.pairedItem = { item: index };
return item;
});
} else {
if (!updateData.length) {
return [];
}
const returnData: INodeExecutionData[] = [];
for (const [index, entry] of mappedValues.entries()) {
returnData.push({
json: entry,
pairedItem: { item: index },
});
}
return returnData;
}
}