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,32 @@
import type {
IExecuteFunctions,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { router } from './actions/router';
import { versionDescription } from './actions/versionDescription';
import { credentialTest, listSearch, loadOptions, resourceMapping } from './methods';
export class GoogleSheetsV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
methods = {
loadOptions,
credentialTest,
listSearch,
resourceMapping,
};
async execute(this: IExecuteFunctions) {
return await router.call(this);
}
}
@@ -0,0 +1,85 @@
import { type IExecuteFunctions, type IDataObject, type INodeExecutionData } from 'n8n-workflow';
import * as sheet from './sheet/Sheet.resource';
import * as spreadsheet from './spreadsheet/SpreadSheet.resource';
import { GoogleSheet } from '../helpers/GoogleSheet';
import type { GoogleSheets, ResourceLocator } from '../helpers/GoogleSheets.types';
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
let operationResult: INodeExecutionData[] = [];
try {
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const googleSheets = {
resource,
operation,
} as GoogleSheets;
let results: INodeExecutionData[] | undefined;
if (googleSheets.resource === 'sheet') {
const { mode, value } = this.getNodeParameter('documentId', 0) as IDataObject;
const spreadsheetId = getSpreadsheetId(
this.getNode(),
mode as ResourceLocator,
value as string,
);
const googleSheet = new GoogleSheet(spreadsheetId, this);
let sheetId = '';
let sheetName = '';
if (operation !== 'create') {
const sheetWithinDocument = this.getNodeParameter('sheetName', 0, undefined, {
extractValue: true,
}) as string;
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as {
mode: ResourceLocator;
};
const result = await googleSheet.spreadsheetGetSheet(
this.getNode(),
sheetMode,
sheetWithinDocument,
);
sheetId = result.sheetId.toString();
sheetName = result.title;
}
switch (operation) {
case 'create':
sheetName = spreadsheetId;
break;
case 'delete':
sheetName = sheetId;
break;
case 'remove':
sheetName = `${spreadsheetId}||${sheetId}`;
break;
}
results = await sheet[googleSheets.operation].execute.call(
this,
googleSheet,
sheetName,
sheetId,
);
} else if (googleSheets.resource === 'spreadsheet') {
results = await spreadsheet[googleSheets.operation].execute.call(this);
}
if (results?.length) {
operationResult = operationResult.concat(results);
}
} catch (error) {
if (this.continueOnFail()) {
operationResult.push({ json: this.getInputData(0)[0].json, error });
} else {
throw error;
}
}
return [operationResult];
}
@@ -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;
}
}
@@ -0,0 +1,37 @@
import type { INodeProperties } from 'n8n-workflow';
import * as create from './create.operation';
import * as deleteSpreadsheet from './delete.operation';
export { create, deleteSpreadsheet };
export const descriptions: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['spreadsheet'],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a spreadsheet',
action: 'Create spreadsheet',
},
{
name: 'Delete',
value: 'deleteSpreadsheet',
description: 'Delete a spreadsheet',
action: 'Delete spreadsheet',
},
],
default: 'create',
},
...create.description,
...deleteSpreadsheet.description,
];
@@ -0,0 +1,160 @@
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
import { wrapData } from '../../../../../../utils/utilities';
import type { SpreadSheetProperties } from '../../helpers/GoogleSheets.types';
import { apiRequest } from '../../transport';
export const description: SpreadSheetProperties = [
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['spreadsheet'],
operation: ['create'],
},
},
description: 'The title of the spreadsheet',
},
{
displayName: 'Sheets',
name: 'sheetsUi',
placeholder: 'Add Sheet',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'sheetValues',
displayName: 'Sheet',
values: [
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
description: 'Title of the property to create',
},
{
displayName: 'Hidden',
name: 'hidden',
type: 'boolean',
default: false,
description: 'Whether the Sheet should be hidden in the UI',
},
],
},
],
displayOptions: {
show: {
resource: ['spreadsheet'],
operation: ['create'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
resource: ['spreadsheet'],
operation: ['create'],
},
},
options: [
{
displayName: 'Locale',
name: 'locale',
type: 'string',
default: '',
placeholder: 'en_US',
description: `The locale of the spreadsheet in one of the following formats:
<ul>
<li>en (639-1)</li>
<li>fil (639-2 if no 639-1 format exists)</li>
<li>en_US (combination of ISO language an country)</li>
<ul>`,
},
{
displayName: 'Recalculation Interval',
name: 'autoRecalc',
type: 'options',
options: [
{
name: 'Default',
value: '',
description: 'Default value',
},
{
name: 'On Change',
value: 'ON_CHANGE',
description: 'Volatile functions are updated on every change',
},
{
name: 'Minute',
value: 'MINUTE',
description: 'Volatile functions are updated on every change and every minute',
},
{
name: 'Hour',
value: 'HOUR',
description: 'Volatile functions are updated on every change and hourly',
},
],
default: '',
description: 'Cell recalculation interval options',
},
],
},
];
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
const title = this.getNodeParameter('title', i) as string;
const sheetsUi = this.getNodeParameter('sheetsUi', i, {}) as IDataObject;
const body = {
properties: {
title,
autoRecalc: undefined as undefined | string,
locale: undefined as undefined | string,
},
sheets: [] as IDataObject[],
};
const options = this.getNodeParameter('options', i, {});
if (Object.keys(sheetsUi).length) {
const data = [];
const sheets = sheetsUi.sheetValues as IDataObject[];
for (const properties of sheets) {
data.push({ properties });
}
body.sheets = data;
}
body.properties.autoRecalc = options.autoRecalc ? (options.autoRecalc as string) : undefined;
body.properties.locale = options.locale ? (options.locale as string) : undefined;
const response = await apiRequest.call(this, 'POST', '/v4/spreadsheets', body);
const executionData = this.helpers.constructExecutionMetaData(
wrapData(response as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
return returnData;
}
@@ -0,0 +1,94 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { wrapData } from '../../../../../../utils/utilities';
import { GOOGLE_DRIVE_FILE_URL_REGEX } from '../../../../constants';
import type { SpreadSheetProperties } from '../../helpers/GoogleSheets.types';
import { apiRequest } from '../../transport';
export const description: SpreadSheetProperties = [
{
displayName: 'Document',
name: 'documentId',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
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: ['spreadsheet'],
operation: ['deleteSpreadsheet'],
},
},
},
];
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
const documentId = this.getNodeParameter('documentId', i, undefined, {
extractValue: true,
}) as string;
await apiRequest.call(
this,
'DELETE',
'',
{},
{},
`https://www.googleapis.com/drive/v3/files/${documentId}`,
);
const executionData = this.helpers.constructExecutionMetaData(wrapData({ success: true }), {
itemData: { item: i },
});
returnData.push(...executionData);
}
return returnData;
}
@@ -0,0 +1,119 @@
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { type GoogleSheet } from '../../helpers/GoogleSheet';
import type {
ILookupValues,
RangeDetectionOptions,
SheetRangeData,
ValueRenderOption,
} from '../../helpers/GoogleSheets.types';
import { getRangeString, prepareSheetData } from '../../helpers/GoogleSheets.utils';
export async function readSheet(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
itemIndex: number,
returnData: INodeExecutionData[],
nodeVersion: number,
items: INodeExecutionData[],
rangeString?: string,
additionalOptions?: IDataObject,
): Promise<INodeExecutionData[]> {
const options = this.getNodeParameter('options', itemIndex, {});
const outputFormattingOption =
((options.outputFormatting as IDataObject)?.values as IDataObject) || {};
const dataLocationOnSheetOptions =
((options.dataLocationOnSheet as IDataObject)?.values as RangeDetectionOptions) ||
additionalOptions ||
{};
if (dataLocationOnSheetOptions.rangeDefinition === undefined) {
dataLocationOnSheetOptions.rangeDefinition = 'detectAutomatically';
}
const includeHeadersWithEmptyCells =
(additionalOptions?.includeHeadersWithEmptyCells as boolean) ?? false;
const range = rangeString ?? getRangeString(sheetName, dataLocationOnSheetOptions);
const valueRenderMode = (outputFormattingOption.general ||
'UNFORMATTED_VALUE') as ValueRenderOption;
const dateTimeRenderOption = (outputFormattingOption.date || 'FORMATTED_STRING') as string;
const sheetData = (await sheet.getData(
range,
valueRenderMode,
dateTimeRenderOption,
)) as SheetRangeData;
if (sheetData === undefined || sheetData.length === 0) {
return [];
}
const {
data,
headerRow: keyRowIndex,
firstDataRow: dataStartRowIndex,
} = prepareSheetData(sheetData, dataLocationOnSheetOptions as RangeDetectionOptions);
let responseData = [];
const lookupValues = this.getNodeParameter('filtersUI.values', itemIndex, []) as ILookupValues[];
const inputData = data as string[][];
if (lookupValues.length) {
let returnAllMatches;
if (nodeVersion < 4.5) {
returnAllMatches = options.returnAllMatches === 'returnAllMatches' ? true : false;
} else {
returnAllMatches =
(additionalOptions?.returnFirstMatch ?? options.returnFirstMatch) ? false : true;
}
if (nodeVersion <= 4.1) {
for (let i = 1; i < items.length; i++) {
const itemLookupValues = this.getNodeParameter(
'filtersUI.values',
i,
[],
) as ILookupValues[];
if (itemLookupValues.length) {
lookupValues.push(...itemLookupValues);
}
}
}
const combineFilters = this.getNodeParameter('combineFilters', itemIndex, 'OR') as 'AND' | 'OR';
responseData = await sheet.lookupValues({
inputData,
keyRowIndex,
dataStartRowIndex,
lookupValues,
returnAllMatches,
nodeVersion,
combineFilters,
});
} else {
responseData = sheet.structureArrayDataByColumn(
inputData,
keyRowIndex,
dataStartRowIndex,
includeHeadersWithEmptyCells,
);
}
returnData.push(
...responseData.map((item) => {
return {
json: item,
pairedItem: { item: itemIndex },
};
}),
);
return returnData;
}
@@ -0,0 +1,118 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import * as sheet from './sheet/Sheet.resource';
import * as spreadsheet from './spreadsheet/SpreadSheet.resource';
export const authentication: INodeProperties = {
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Service Account',
value: 'serviceAccount',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'OAuth2 (recommended)',
value: 'oAuth2',
},
],
default: 'oAuth2',
};
export const versionDescription: INodeTypeDescription = {
displayName: 'Google Sheets',
name: 'googleSheets',
icon: 'file:googleSheets.svg',
group: ['input', 'output'],
version: [3, 4, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Read, update and write data to Google Sheets',
defaults: {
name: 'Google Sheets',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
usableAsTool: true,
hints: [
{
message:
"Use the 'Minimise API Calls' option for greater efficiency if your sheet is uniformly formatted without gaps between columns or rows",
displayCondition:
'={{$parameter["operation"] === "append" && !$parameter["options"]["useAppend"]}}',
whenToDisplay: 'beforeExecution',
location: 'outputPane',
},
{
message: 'No columns found in Google Sheet. All rows will be appended',
displayCondition:
'={{ ["appendOrUpdate", "append"].includes($parameter["operation"]) && $parameter?.columns?.mappingMode === "defineBelow" && !$parameter?.columns?.schema?.length }}',
whenToDisplay: 'beforeExecution',
location: 'outputPane',
},
{
type: 'info',
message:
'Note on using an expression for Sheet: It will be evaluated only once, so all items will use the <em>same</em> sheet. It will be calculated by evaluating the expression for the <strong>first input item</strong>.',
displayCondition:
'={{ $rawParameter.sheetName?.startsWith("=") && $input.all().length > 1 }}',
whenToDisplay: 'always',
location: 'outputPane',
},
{
type: 'info',
message:
'Note on using an expression for Document: It will be evaluated only once, so all items will use the <em>same</em> document. It will be calculated by evaluating the expression for the <strong>first input item</strong>.',
displayCondition:
'={{ $rawParameter.documentId?.startsWith("=") && $input.all().length > 1 }}',
whenToDisplay: 'always',
location: 'outputPane',
},
],
credentials: [
{
name: 'googleApi',
required: true,
displayOptions: {
show: {
authentication: ['serviceAccount'],
},
},
testedBy: 'googleApiCredentialTest',
},
{
name: 'googleSheetsOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
properties: [
authentication,
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Document',
value: 'spreadsheet',
},
{
name: 'Sheet Within Document',
value: 'sheet',
},
],
default: 'sheet',
},
...sheet.descriptions,
...spreadsheet.descriptions,
],
};
@@ -0,0 +1,875 @@
import get from 'lodash/get';
import type {
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
INode,
IPollFunctions,
} from 'n8n-workflow';
import { ApplicationError, NodeOperationError } from 'n8n-workflow';
import { utils as xlsxUtils } from 'xlsx';
import type {
ILookupValues,
ISheetUpdateData,
ResourceLocator,
SheetCellDecoded,
SheetRangeData,
SheetRangeDecoded,
SpreadSheetResponse,
ValueInputOption,
ValueRenderOption,
} from './GoogleSheets.types';
import { getSheetId, removeEmptyColumns } from './GoogleSheets.utils';
import { apiRequest } from '../transport';
export class GoogleSheet {
id: string;
executeFunctions: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions;
constructor(
spreadsheetId: string,
executeFunctions: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
) {
this.executeFunctions = executeFunctions;
this.id = spreadsheetId;
}
/**
* Encodes the range that also none latin character work
*
* @param {string} range
* @returns {string}
* @memberof GoogleSheet
*/
private encodeRange(range: string): string {
if (range.includes('!')) {
const [sheet, ranges] = range.split('!');
return `${encodeURIComponent(sheet)}!${ranges}`;
}
// Use '' so that sheet is not interpreted as range
return encodeURIComponent(`'${range}'`);
}
/**
* Clears values from a sheet
*
* @param {string} range
* @returns {Promise<object>}
* @memberof GoogleSheet
*/
async clearData(range: string): Promise<object> {
const body = {
spreadsheetId: this.id,
range,
};
const response = await apiRequest.call(
this.executeFunctions,
'POST',
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}:clear`,
body,
);
return response;
}
/**
* Returns the cell values
*/
async getData(range: string, valueRenderMode: ValueRenderOption, dateTimeRenderOption?: string) {
const query: IDataObject = {
valueRenderOption: valueRenderMode,
dateTimeRenderOption: 'FORMATTED_STRING',
};
if (dateTimeRenderOption) {
query.dateTimeRenderOption = dateTimeRenderOption;
}
const response = await apiRequest.call(
this.executeFunctions,
'GET',
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}`,
{},
query,
);
return response.values as string[][] | undefined;
}
/**
* Returns the sheets in a Spreadsheet
*/
async spreadsheetGetSheets() {
const query = {
fields: 'sheets.properties',
};
const response = await apiRequest.call(
this.executeFunctions,
'GET',
`/v4/spreadsheets/${this.id}`,
{},
query,
);
return response;
}
/**
* Returns the sheet within a spreadsheet based on name or ID
*/
async spreadsheetGetSheet(node: INode, mode: ResourceLocator, value: string) {
const query = {
fields: 'sheets.properties',
};
const response = (await apiRequest.call(
this.executeFunctions,
'GET',
`/v4/spreadsheets/${this.id}`,
{},
query,
)) as SpreadSheetResponse;
const foundItem = response.sheets.find((item) => {
if (mode === 'name') return item.properties.title === value;
return item.properties.sheetId === getSheetId(value);
});
if (!foundItem?.properties?.title) {
const error = new Error(`Sheet with ${mode === 'name' ? 'name' : 'ID'} ${value} not found`);
throw new NodeOperationError(node, error, { level: 'warning' });
}
return foundItem.properties;
}
/**
* Returns the grid properties of a sheet
*/
async getDataRange(sheetId: string) {
const query = {
fields: 'sheets.properties',
};
const response = await apiRequest.call(
this.executeFunctions,
'GET',
`/v4/spreadsheets/${this.id}`,
{},
query,
);
const foundItem = response.sheets.find(
(item: { properties: { sheetId: string } }) => item.properties.sheetId === sheetId,
);
return foundItem.properties.gridProperties;
}
/**
* Sets values in one or more ranges of a spreadsheet.
*/
async spreadsheetBatchUpdate(requests: IDataObject[]) {
const body = {
requests,
};
const response = await apiRequest.call(
this.executeFunctions,
'POST',
`/v4/spreadsheets/${this.id}:batchUpdate`,
body,
);
return response;
}
/**
* Sets the cell values
*/
async batchUpdate(updateData: ISheetUpdateData[], valueInputMode: ValueInputOption) {
const body = {
data: updateData,
valueInputOption: valueInputMode,
};
const response = await apiRequest.call(
this.executeFunctions,
'POST',
`/v4/spreadsheets/${this.id}/values:batchUpdate`,
body,
);
return response;
}
async appendEmptyRowsOrColumns(sheetId: string, rowsToAdd = 1, columnsToAdd = 1) {
const requests: IDataObject[] = [];
if (rowsToAdd > 0) {
requests.push({
appendDimension: {
sheetId,
dimension: 'ROWS',
length: rowsToAdd,
},
});
}
if (columnsToAdd > 0) {
requests.push({
appendDimension: {
sheetId,
dimension: 'COLUMNS',
length: columnsToAdd,
},
});
}
if (requests.length === 0) {
throw new ApplicationError('Must specify at least one column or row to add', {
level: 'warning',
});
}
const response = await apiRequest.call(
this.executeFunctions,
'POST',
`/v4/spreadsheets/${this.id}:batchUpdate`,
{ requests },
);
return response;
}
/**
* Appends the cell values
*/
async appendData(
range: string,
data: string[][],
valueInputMode: ValueInputOption,
lastRow?: number,
useAppend?: boolean,
) {
const lastRowWithData =
lastRow ||
(((await this.getData(range, 'UNFORMATTED_VALUE')) as string[][]) || []).length + 1;
const response = await this.updateRows(
range,
data,
valueInputMode,
lastRowWithData,
data.length,
useAppend,
);
return response;
}
async updateRows(
sheetName: string,
data: string[][],
valueInputMode: ValueInputOption,
row: number,
rowsLength?: number,
useAppend?: boolean,
) {
const [name, _sheetRange] = sheetName.split('!');
const range = `${name}!${row}:${rowsLength ? row + rowsLength - 1 : row}`;
const body = {
range,
values: data,
};
const query = {
valueInputOption: valueInputMode,
};
let response;
if (useAppend) {
response = await apiRequest.call(
this.executeFunctions,
'POST',
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}:append`,
body,
query,
);
} else {
response = await apiRequest.call(
this.executeFunctions,
'PUT',
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}`,
body,
query,
);
}
return response;
}
/**
* Returns the given sheet data in a structured way
*/
convertSheetDataArrayToObjectArray(
sheet: SheetRangeData,
startRow: number,
columnKeys: string[],
addEmpty?: boolean,
includeHeadersWithEmptyCells?: boolean,
): IDataObject[] {
const returnData = [];
for (let rowIndex = startRow; rowIndex < sheet.length; rowIndex++) {
const item: IDataObject = {};
const rowCount = sheet[rowIndex].length;
const columnCount = includeHeadersWithEmptyCells ? columnKeys.length : rowCount;
for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
const key = columnKeys[columnIndex];
if (key) {
item[key] = sheet[rowIndex][columnIndex] ?? '';
}
}
if (Object.keys(item).length || addEmpty === true) {
returnData.push(item);
}
}
return returnData;
}
/**
* Returns the given sheet data in a structured way using
* the startRow as the one with the name of the key
*/
structureArrayDataByColumn(
inputData: string[][],
keyRow: number,
dataStartRow: number,
includeHeadersWithEmptyCells?: boolean,
): IDataObject[] {
const keys: string[] = [];
if (keyRow < 0 || dataStartRow < keyRow || keyRow >= inputData.length) {
// The key row does not exist so it is not possible to structure data
return [];
}
const longestRow = inputData.reduce((a, b) => (a.length > b.length ? a : b), []).length;
for (let columnIndex = 0; columnIndex < longestRow; columnIndex++) {
keys.push(inputData[keyRow][columnIndex] || `col_${columnIndex}`);
}
return this.convertSheetDataArrayToObjectArray(
inputData,
dataStartRow,
keys,
false,
includeHeadersWithEmptyCells,
);
}
testFilter(inputData: string[][], keyRow: number, dataStartRow: number): string[] {
const keys: string[] = [];
//const returnData = [];
if (keyRow < 0 || dataStartRow < keyRow || keyRow >= inputData.length) {
// The key row does not exist so it is not possible to structure data
return [];
}
// Create the keys array
for (let columnIndex = 0; columnIndex < inputData[keyRow].length; columnIndex++) {
keys.push(inputData[keyRow][columnIndex]);
}
return keys;
}
async appendSheetData({
inputData,
range,
keyRowIndex,
valueInputMode,
usePathForKeyRow,
columnNamesList,
lastRow,
useAppend,
}: {
inputData: IDataObject[];
range: string;
keyRowIndex: number;
valueInputMode: ValueInputOption;
usePathForKeyRow?: boolean;
columnNamesList?: string[][];
lastRow?: number;
useAppend?: boolean;
}): Promise<string[][]> {
const data = await this.convertObjectArrayToSheetDataArray(
inputData,
range,
keyRowIndex,
usePathForKeyRow,
columnNamesList,
useAppend ? null : '',
);
return await this.appendData(range, data, valueInputMode, lastRow, useAppend);
}
getColumnWithOffset(startColumn: string, offset: number): string {
const columnIndex = xlsxUtils.decode_col(startColumn) + offset;
return xlsxUtils.encode_col(columnIndex);
}
async getColumnValues({
range,
keyIndex,
dataStartRowIndex,
valueRenderMode,
sheetData,
}: {
range: string;
keyIndex: number;
dataStartRowIndex: number;
valueRenderMode: ValueRenderOption;
sheetData?: string[][];
}): Promise<string[]> {
let columnValuesList;
if (sheetData) {
columnValuesList = sheetData.slice(dataStartRowIndex - 1).map((row) => row[keyIndex]);
} else {
const decodedRange = this.getDecodedSheetRange(range);
const startRowIndex = decodedRange.start?.row || dataStartRowIndex;
const endRowIndex = decodedRange.end?.row || '';
const keyColumn = this.getColumnWithOffset(decodedRange.start?.column || 'A', keyIndex);
const keyColumnRange = `${decodedRange.name}!${keyColumn}${startRowIndex}:${keyColumn}${endRowIndex}`;
columnValuesList = await this.getData(keyColumnRange, valueRenderMode);
}
if (columnValuesList === undefined) {
throw new NodeOperationError(
this.executeFunctions.getNode(),
'Could not retrieve the data from key column',
);
}
//Remove the first row which contains the key and flaten the array
return columnValuesList.splice(1).flatMap((value) => value);
}
/**
* Updates data in a sheet
*
* @param {IDataObject[]} inputData Data to update Sheet with
* @param {string} indexKey The name of the key which gets used to know which rows to update
* @param {string} range The range to look for data
* @param {number} keyRowIndex Index of the row which contains the keys
* @param {number} dataStartRowIndex Index of the first row which contains data
* @returns {Promise<string[][]>}
* @memberof GoogleSheet
*/
async prepareDataForUpdateOrUpsert({
inputData,
indexKey,
range,
keyRowIndex,
dataStartRowIndex,
valueRenderMode,
upsert = false,
columnNamesList,
columnValuesList,
}: {
inputData: IDataObject[];
indexKey: string;
range: string;
keyRowIndex: number;
dataStartRowIndex: number;
valueRenderMode: ValueRenderOption;
upsert?: boolean;
columnNamesList?: string[][];
columnValuesList?: string[];
}) {
const decodedRange = this.getDecodedSheetRange(range);
const keyRowRange = `${decodedRange.name}!${decodedRange.start?.column || ''}${keyRowIndex + 1}:${decodedRange.end?.column || ''}${keyRowIndex + 1}`;
const sheetDatakeyRow = columnNamesList || (await this.getData(keyRowRange, valueRenderMode));
if (sheetDatakeyRow === undefined) {
throw new NodeOperationError(
this.executeFunctions.getNode(),
'Could not retrieve the key row',
);
}
const columnNames = sheetDatakeyRow[0];
const keyIndex = columnNames.indexOf(indexKey);
if (keyIndex === -1 && !upsert) {
throw new NodeOperationError(
this.executeFunctions.getNode(),
`Could not find column for key "${indexKey}"`,
);
}
const columnValues: Array<string | number> =
columnValuesList ||
(await this.getColumnValues({ range, keyIndex, dataStartRowIndex, valueRenderMode }));
const updateData: ISheetUpdateData[] = [];
const appendData: IDataObject[] = [];
const getKeyIndex = (key: string | number, data: Array<string | number>) => {
let index = -1;
for (let i = 0; i < data.length; i++) {
if (data[i]?.toString() === key.toString()) {
index = i;
break;
}
}
return index;
};
for (const item of inputData) {
const inputIndexKey = item[indexKey] as string;
if (inputIndexKey === undefined || inputIndexKey === null) {
// Item does not have the indexKey so we can ignore it or append it if upsert true
if (upsert) {
appendData.push(item);
}
continue;
}
// Item does have the key so check if it exists in Sheet
const indexOfIndexKeyInSheet = getKeyIndex(inputIndexKey, columnValues);
if (indexOfIndexKeyInSheet === -1) {
// Key does not exist in the Sheet so it can not be updated so skip it or append it if upsert true
if (upsert) {
appendData.push(item);
}
continue;
}
// Get the row index in which the data should be updated
const updateRowIndex = indexOfIndexKeyInSheet + dataStartRowIndex + 1;
// Check all the properties in the sheet and check which ones exist on the
// item and should be updated
for (const name of columnNames) {
if (name === indexKey) {
// Ignore the key itself as that does not get changed it gets
// only used to find the correct row to update
continue;
}
if (item[name] === undefined || item[name] === null) {
// Property does not exist so skip it
continue;
}
// Property exists so add it to the data to update
// Get the column name in which the property data can be found
const columnToUpdate = this.getColumnWithOffset(
decodedRange.start?.column || 'A',
columnNames.indexOf(name),
);
let updateValue = item[name] as string;
if (typeof updateValue === 'object') {
try {
updateValue = JSON.stringify(updateValue);
} catch (error) {}
}
updateData.push({
range: `${decodedRange.name}!${columnToUpdate}${updateRowIndex}`,
values: [[updateValue]],
});
}
}
return { updateData, appendData };
}
/**
* Updates data in a sheet
*
* @param {IDataObject[]} inputData Data to update Sheet with
* @param {string} range The range to look for data
* @param {number} dataStartRowIndex Index of the first row which contains data
* @param {string[][]} columnNamesList The column names to use
* @returns {Promise<string[][]>}
* @memberof GoogleSheet
*/
prepareDataForUpdatingByRowNumber(
inputData: IDataObject[],
range: string,
columnNamesList: string[][],
) {
const decodedRange = this.getDecodedSheetRange(range);
const columnNames = columnNamesList[0];
const updateData: ISheetUpdateData[] = [];
for (const item of inputData) {
const updateRowIndex = item.row_number as number;
for (const name of columnNames) {
if (name === 'row_number') continue;
if (item[name] === undefined || item[name] === null) continue;
const columnToUpdate = this.getColumnWithOffset(
decodedRange.start?.column || 'A',
columnNames.indexOf(name),
);
let updateValue = item[name] as string;
if (typeof updateValue === 'object') {
try {
updateValue = JSON.stringify(updateValue);
} catch (error) {}
}
updateData.push({
range: `${decodedRange.name}!${columnToUpdate}${updateRowIndex}`,
values: [[updateValue]],
});
}
}
return { updateData };
}
/**
* Looks for a specific value in a column and if it gets found it returns the whole row
*
* @param {string[][]} inputData Data to check for lookup value in
* @param {number} keyRowIndex Index of the row which contains the keys
* @param {number} dataStartRowIndex Index of the first row which contains data
* @param {ILookupValues[]} lookupValues The lookup values which decide what data to return
* @param {boolean} [returnAllMatches] Returns all the found matches instead of only the first one
* @returns {Promise<IDataObject[]>}
* @memberof GoogleSheet
*/
async lookupValues({
inputData,
keyRowIndex,
dataStartRowIndex,
lookupValues,
returnAllMatches,
nodeVersion,
combineFilters = 'OR',
}: {
inputData: string[][];
keyRowIndex: number;
dataStartRowIndex: number;
lookupValues: ILookupValues[];
nodeVersion: number;
returnAllMatches?: boolean;
combineFilters?: 'AND' | 'OR';
}): Promise<IDataObject[]> {
const keys: string[] = [];
if (keyRowIndex < 0 || dataStartRowIndex < keyRowIndex || keyRowIndex >= inputData.length) {
// The key row does not exist so it is not possible to look up the data
throw new NodeOperationError(this.executeFunctions.getNode(), 'The key row does not exist');
}
// Create the keys array
for (let columnIndex = 0; columnIndex < inputData[keyRowIndex].length; columnIndex++) {
keys.push(inputData[keyRowIndex][columnIndex] || `col_${columnIndex}`);
}
// Standardize values array, if rows is [[]], map it to [['']] (Keep the columns into consideration)
for (let rowIndex = 0; rowIndex < inputData?.length; rowIndex++) {
if (inputData[rowIndex].length === 0) {
for (let i = 0; i < keys.length; i++) {
inputData[rowIndex][i] = '';
}
} else if (inputData[rowIndex].length < keys.length) {
for (let i = 0; i < keys.length; i++) {
if (inputData[rowIndex][i] === undefined) {
inputData[rowIndex].push('');
}
}
}
}
// Loop over all the lookup values and try to find a row to return
let rowIndex: number;
let returnColumnIndex: number;
const addedRows: number[] = [];
// const returnData = [inputData[keyRowIndex]];
const returnData = [keys];
if (combineFilters === 'OR') {
lookupLoop: for (const lookupValue of lookupValues) {
returnColumnIndex = keys.indexOf(lookupValue.lookupColumn);
if (returnColumnIndex === -1) {
throw new NodeOperationError(
this.executeFunctions.getNode(),
`The column "${lookupValue.lookupColumn}" could not be found`,
);
}
// Loop over all the items and find the one with the matching value
for (rowIndex = dataStartRowIndex; rowIndex < inputData.length; rowIndex++) {
if (
inputData[rowIndex][returnColumnIndex]?.toString() ===
lookupValue.lookupValue.toString()
) {
if (addedRows.indexOf(rowIndex) === -1) {
returnData.push(inputData[rowIndex]);
addedRows.push(rowIndex);
}
if (returnAllMatches !== true) {
if (nodeVersion >= 4.6) {
break lookupLoop;
}
continue lookupLoop;
}
}
}
}
} else {
lookupLoop: for (rowIndex = dataStartRowIndex; rowIndex < inputData.length; rowIndex++) {
let allMatch = true;
for (const lookupValue of lookupValues) {
returnColumnIndex = keys.indexOf(lookupValue.lookupColumn);
if (returnColumnIndex === -1) {
throw new NodeOperationError(
this.executeFunctions.getNode(),
`The column "${lookupValue.lookupColumn}" could not be found`,
);
}
if (
inputData[rowIndex][returnColumnIndex]?.toString() !==
lookupValue.lookupValue.toString()
) {
allMatch = false;
break;
}
}
if (allMatch) {
if (addedRows.indexOf(rowIndex) === -1) {
returnData.push(inputData[rowIndex]);
addedRows.push(rowIndex);
}
if (returnAllMatches !== true) {
break lookupLoop;
}
}
}
}
const dataWithoutEmptyColumns = removeEmptyColumns(returnData);
return this.convertSheetDataArrayToObjectArray(
dataWithoutEmptyColumns,
1,
dataWithoutEmptyColumns[0] as string[],
true,
);
}
private async convertObjectArrayToSheetDataArray(
inputData: IDataObject[],
range: string,
keyRowIndex: number,
usePathForKeyRow?: boolean,
columnNamesList?: string[][],
emptyValue: string | null = '',
): Promise<string[][]> {
const decodedRange = this.getDecodedSheetRange(range);
const columnNamesRow =
columnNamesList ||
(await this.getData(
`${decodedRange.name}!${keyRowIndex}:${keyRowIndex}`,
'UNFORMATTED_VALUE',
));
if (columnNamesRow === undefined) {
throw new NodeOperationError(
this.executeFunctions.getNode(),
'Could not retrieve the column data',
);
}
const columnNames = columnNamesRow ? columnNamesRow[0] : [];
const setData: string[][] = [];
inputData.forEach((item) => {
const rowData: string[] = [];
columnNames.forEach((key) => {
let value;
if (usePathForKeyRow) {
value = get(item, key) as string;
} else {
value = item[key] as string;
}
if (value === undefined || value === null) {
rowData.push(emptyValue as string);
return;
}
if (typeof value === 'object') {
rowData.push(JSON.stringify(value));
} else {
rowData.push(value);
}
});
setData.push(rowData);
});
return setData;
}
private getDecodedSheetRange(stringToDecode: string): SheetRangeDecoded {
const decodedRange: IDataObject = {};
const [name, range] = stringToDecode.split('!');
decodedRange.nameWithRange = stringToDecode;
decodedRange.name = name;
decodedRange.range = range || '';
decodedRange.start = {};
decodedRange.end = {};
if (range) {
const [startCell, endCell] = range.split(':');
if (startCell) {
decodedRange.start = this.splitCellRange(startCell, range);
}
if (endCell) {
decodedRange.end = this.splitCellRange(endCell, range);
}
}
return decodedRange as SheetRangeDecoded;
}
private splitCellRange(cell: string, range: string): SheetCellDecoded {
const cellData = cell.match(/([a-zA-Z]{1,10})([0-9]{0,10})/) || [];
if (cellData === null || cellData.length !== 3) {
throw new NodeOperationError(
this.executeFunctions.getNode(),
`The range "${range}" is not valid`,
);
}
return { cell: cellData[0], column: cellData[1], row: +cellData[2] };
}
}
@@ -0,0 +1,97 @@
import type { AllEntities, Entity, PropertiesOf } from 'n8n-workflow';
export const ROW_NUMBER = 'row_number';
export interface ISheetOptions {
scope: string[];
}
export interface IGoogleAuthCredentials {
email: string;
privateKey: string;
}
export interface ISheetUpdateData {
range: string;
values: string[][];
}
export interface ILookupValues {
lookupColumn: string;
lookupValue: string;
}
export interface IToDeleteRange {
amount: number;
startIndex: number;
sheetId: number;
}
export interface IToDelete {
[key: string]: IToDeleteRange[] | undefined;
columns?: IToDeleteRange[];
rows?: IToDeleteRange[];
}
export type ValueInputOption = 'RAW' | 'USER_ENTERED';
export type ValueRenderOption = 'FORMATTED_VALUE' | 'FORMULA' | 'UNFORMATTED_VALUE';
export type RangeDetectionOptions = {
rangeDefinition: 'detectAutomatically' | 'specifyRange' | 'specifyRangeA1';
readRowsUntil?: 'firstEmptyRow' | 'lastRowInSheet';
headerRow?: string;
firstDataRow?: string;
range?: string;
};
export type SheetDataRow = Array<string | number>;
export type SheetRangeData = SheetDataRow[];
// delete is del
type GoogleSheetsMap = {
spreadsheet: 'create' | 'deleteSpreadsheet';
sheet: 'append' | 'clear' | 'create' | 'delete' | 'read' | 'remove' | 'update' | 'appendOrUpdate';
};
export type GoogleSheets = AllEntities<GoogleSheetsMap>;
export type GoogleSheetsSpreadSheet = Entity<GoogleSheetsMap, 'spreadsheet'>;
export type GoogleSheetsSheet = Entity<GoogleSheetsMap, 'sheet'>;
export type SpreadSheetProperties = PropertiesOf<GoogleSheetsSpreadSheet>;
export type SheetProperties = PropertiesOf<GoogleSheetsSheet>;
export type ResourceLocator = 'id' | 'url' | 'list' | 'name';
export const ResourceLocatorUiNames = {
id: 'By ID',
url: 'By URL',
list: 'From List',
name: 'By Name',
};
type SpreadSheetResponseSheet = {
properties: {
title: string;
sheetId: number;
};
};
export type SpreadSheetResponse = {
sheets: SpreadSheetResponseSheet[];
};
export type SheetCellDecoded = {
cell?: string;
column?: string;
row?: number;
};
export type SheetRangeDecoded = {
nameWithRange: string;
name: string;
range: string;
start?: SheetCellDecoded;
end?: SheetCellDecoded;
};
@@ -0,0 +1,369 @@
import type {
IExecuteFunctions,
IDataObject,
INodeExecutionData,
INodeListSearchItems,
INodePropertyOptions,
INode,
ResourceMapperField,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { GoogleSheet } from './GoogleSheet';
import type {
RangeDetectionOptions,
ResourceLocator,
SheetRangeData,
ValueInputOption,
} from './GoogleSheets.types';
import { ResourceLocatorUiNames, ROW_NUMBER } from './GoogleSheets.types';
export const untilSheetSelected = { sheetName: [''] };
// Used to extract the ID from the URL
export function getSpreadsheetId(
node: INode,
documentIdType: ResourceLocator,
value: string,
): string {
if (!value) {
throw new NodeOperationError(
node,
`Can not get sheet '${ResourceLocatorUiNames[documentIdType]}' with a value of '${value}'`,
{ level: 'warning' },
);
}
if (documentIdType === 'url') {
const regex = /([-\w]{25,})/;
const parts = value.match(regex);
if (parts == null || parts.length < 2) {
return '';
} else {
return parts[0];
}
}
// If it is byID or byList we can just return
return value;
}
export function getSheetId(value: string): number {
if (value === 'gid=0') return 0;
return parseInt(value);
}
// Convert number to Sheets / Excel column name
export function getColumnName(colNumber: number): string {
const baseChar = 'A'.charCodeAt(0);
let letters = '';
do {
colNumber -= 1;
letters = String.fromCharCode(baseChar + (colNumber % 26)) + letters;
colNumber = (colNumber / 26) >> 0;
} while (colNumber > 0);
return letters;
}
// Convert Column Name to Number (A = 1, B = 2, AA = 27)
export function getColumnNumber(colPosition: string): number {
let colNum = 0;
for (let i = 0; i < colPosition.length; i++) {
colNum *= 26;
colNum += colPosition[i].charCodeAt(0) - 'A'.charCodeAt(0) + 1;
}
return colNum;
}
// Hex to RGB
export function hexToRgb(hex: string) {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (_, r, g, b) => {
return r + r + g + g + b + b;
});
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
return {
red: parseInt(result[1], 16),
green: parseInt(result[2], 16),
blue: parseInt(result[3], 16),
};
} else {
return null;
}
}
export function addRowNumber(data: SheetRangeData, headerRow: number) {
if (data.length === 0) return data;
const sheetData = data.map((row, i) => [i + 1, ...row]);
sheetData[headerRow][0] = ROW_NUMBER;
return sheetData;
}
export function trimToFirstEmptyRow(data: SheetRangeData, includesRowNumber = true) {
const baseLength = includesRowNumber ? 1 : 0;
const emptyRowIndex = data.findIndex((row) => row.slice(baseLength).every((cell) => cell === ''));
if (emptyRowIndex === -1) {
return data;
}
return data.slice(0, emptyRowIndex);
}
export function removeEmptyRows(data: SheetRangeData, includesRowNumber = true) {
const baseLength = includesRowNumber ? 1 : 0;
const notEmptyRows = data.filter((row) =>
row
.slice(baseLength)
.some((cell) => cell || typeof cell === 'number' || typeof cell === 'boolean'),
);
if (includesRowNumber) {
notEmptyRows[0][0] = ROW_NUMBER;
}
return notEmptyRows;
}
export function trimLeadingEmptyRows(
data: SheetRangeData,
includesRowNumber = true,
rowNumbersColumnName = ROW_NUMBER,
) {
const baseLength = includesRowNumber ? 1 : 0;
const firstNotEmptyRowIndex = data.findIndex((row) =>
row.slice(baseLength).some((cell) => cell || typeof cell === 'number'),
);
const returnData = data.slice(firstNotEmptyRowIndex);
if (includesRowNumber) {
returnData[0][0] = rowNumbersColumnName;
}
return returnData;
}
export function removeEmptyColumns(data: SheetRangeData) {
if (!data || data.length === 0) return [];
const returnData: SheetRangeData = [];
const longestRow = data.reduce((a, b) => (a.length > b.length ? a : b), []).length;
for (let col = 0; col < longestRow; col++) {
const column = data.map((row) => row[col]);
if (column[0] !== '') {
returnData.push(column);
continue;
}
const hasData = column.slice(1).some((cell) => cell || typeof cell === 'number');
if (hasData) {
returnData.push(column);
}
}
return (returnData[0] || []).map((_, i) =>
returnData.map((row) => (row[i] === undefined ? '' : row[i])),
);
}
export function prepareSheetData(
data: SheetRangeData,
options: RangeDetectionOptions,
addRowNumbersToData = true,
) {
let returnData: SheetRangeData = [...(data || [])];
let headerRow = 0;
let firstDataRow = 1;
if (options.rangeDefinition === 'specifyRange') {
headerRow = parseInt(options.headerRow as string, 10) - 1;
firstDataRow = parseInt(options.firstDataRow as string, 10) - 1;
}
if (addRowNumbersToData) {
returnData = addRowNumber(returnData, headerRow);
}
if (options.rangeDefinition === 'detectAutomatically') {
returnData = removeEmptyColumns(returnData);
returnData = trimLeadingEmptyRows(returnData, addRowNumbersToData);
if (options.readRowsUntil === 'firstEmptyRow') {
returnData = trimToFirstEmptyRow(returnData, addRowNumbersToData);
} else {
returnData = removeEmptyRows(returnData, addRowNumbersToData);
}
}
return { data: returnData, headerRow, firstDataRow };
}
export function getRangeString(sheetName: string, options: RangeDetectionOptions) {
if (options.rangeDefinition === 'specifyRangeA1') {
return options.range ? `${sheetName}!${options.range}` : sheetName;
}
return sheetName;
}
export async function getExistingSheetNames(sheet: GoogleSheet) {
const { sheets } = await sheet.spreadsheetGetSheets();
return ((sheets as IDataObject[]) || []).map((entry) => (entry.properties as IDataObject)?.title);
}
export function mapFields(this: IExecuteFunctions, inputSize: number) {
const returnData: IDataObject[] = [];
for (let i = 0; i < inputSize; i++) {
const nodeVersion = this.getNode().typeVersion;
if (nodeVersion < 4) {
const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as IDataObject[];
let dataToSend: IDataObject = {};
for (const field of fields) {
dataToSend = { ...dataToSend, [field.fieldId as string]: field.fieldValue };
}
returnData.push(dataToSend);
} 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'",
);
}
returnData.push(mappingValues);
}
}
return returnData;
}
export async function autoMapInputData(
this: IExecuteFunctions,
sheetNameWithRange: string,
sheet: GoogleSheet,
items: INodeExecutionData[],
options: IDataObject,
) {
const returnData: IDataObject[] = [];
const [sheetName, _sheetRange] = sheetNameWithRange.split('!');
const locationDefine = (options.locationDefine as IDataObject)?.values as IDataObject;
const handlingExtraData = (options.handlingExtraData as string) || 'insertInNewColumn';
let headerRow = 1;
if (locationDefine) {
headerRow = parseInt(locationDefine.headerRow as string, 10);
}
let columnNames: string[] = [];
const response = await sheet.getData(`${sheetName}!${headerRow}:${headerRow}`, 'FORMATTED_VALUE');
columnNames = response ? response[0] : [];
if (handlingExtraData === 'insertInNewColumn') {
if (!columnNames.length) {
await sheet.updateRows(
sheetName,
[Object.keys(items[0].json).filter((key) => key !== ROW_NUMBER)],
(options.cellFormat as ValueInputOption) || 'RAW',
headerRow,
);
columnNames = Object.keys(items[0].json);
}
const newColumns = new Set<string>();
items.forEach((item) => {
Object.keys(item.json).forEach((key) => {
if (key !== ROW_NUMBER && !columnNames.includes(key)) {
newColumns.add(key);
}
});
if (item.json[ROW_NUMBER]) {
const { [ROW_NUMBER]: _, ...json } = item.json;
returnData.push(json);
return;
}
returnData.push(item.json);
});
if (newColumns.size) {
await sheet.updateRows(
sheetName,
[columnNames.concat([...newColumns])],
(options.cellFormat as ValueInputOption) || 'RAW',
headerRow,
);
}
}
if (handlingExtraData === 'ignoreIt') {
items.forEach((item) => {
returnData.push(item.json);
});
}
if (handlingExtraData === 'error') {
items.forEach((item, itemIndex) => {
Object.keys(item.json).forEach((key) => {
if (!columnNames.includes(key)) {
throw new NodeOperationError(this.getNode(), 'Unexpected fields in node input', {
itemIndex,
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'.`,
});
}
});
returnData.push(item.json);
});
}
return returnData;
}
export function sortLoadOptions(data: INodePropertyOptions[] | INodeListSearchItems[]) {
const returnData = [...data];
returnData.sort((a, b) => {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
if (aName < bName) {
return -1;
}
if (aName > bName) {
return 1;
}
return 0;
});
return returnData;
}
export function cellFormatDefault(nodeVersion: number) {
if (nodeVersion < 4.1) {
return 'RAW';
}
return 'USER_ENTERED';
}
export function checkForSchemaChanges(
node: INode,
columnNames: string[],
schema: ResourceMapperField[],
) {
const updatedColumnNames: Array<{ oldName: string; newName: string }> = [];
// RMC filters out empty columns so do the same here
columnNames = columnNames.filter((col) => col !== '');
// if sheet does not contain ROW_NUMBER ignore it as data come from read rows operation
const schemaColumns = columnNames.includes(ROW_NUMBER)
? schema.map((s) => s.id)
: schema.filter((s) => s.id !== ROW_NUMBER).map((s) => s.id);
for (const [columnIndex, columnName] of columnNames.entries()) {
const schemaEntry = schemaColumns[columnIndex];
if (schemaEntry === undefined) break;
if (columnName !== schemaEntry) {
updatedColumnNames.push({ oldName: schemaEntry, newName: columnName });
}
}
if (updatedColumnNames.length) {
throw new NodeOperationError(node, "Column names were updated after the node's setup", {
description: `Refresh the columns list in the 'Column to Match On' parameter. Updated columns: ${updatedColumnNames.map((c) => `${c.oldName} -> ${c.newName}`).join(', ')}`,
});
}
}
@@ -0,0 +1,32 @@
import type {
ICredentialsDecrypted,
ICredentialTestFunctions,
INodeCredentialTestResult,
} from 'n8n-workflow';
import { getGoogleAccessToken } from '../../../GenericFunctions';
export async function googleApiCredentialTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
try {
const tokenRequest = await getGoogleAccessToken.call(this, credential.data!, 'sheetV2');
if (!tokenRequest.access_token) {
return {
status: 'Error',
message: 'Could not generate a token from your private key.',
};
}
} catch (err) {
return {
status: 'Error',
message: `Private key validation failed: ${err.message}`,
};
}
return {
status: 'OK',
message: 'Connection successful!',
};
}
@@ -0,0 +1,4 @@
export * as loadOptions from './loadOptions';
export * as listSearch from './listSearch';
export * as credentialTest from './credentialTest';
export * as resourceMapping from './resourceMapping';
@@ -0,0 +1,93 @@
import type {
IDataObject,
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { ResourceLocator } from '../helpers/GoogleSheets.types';
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
import { apiRequest } from '../transport';
export async function spreadSheetsSearch(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const query: string[] = [];
if (filter) {
query.push(`name contains '${filter.replace("'", "\\'")}'`);
}
query.push("mimeType = 'application/vnd.google-apps.spreadsheet'");
const qs = {
q: query.join(' and '),
pageToken: (paginationToken as string) || undefined,
fields: 'nextPageToken, files(id, name, webViewLink)',
orderBy: 'modifiedByMeTime desc,name_natural',
includeItemsFromAllDrives: true,
supportsAllDrives: true,
};
const res = await apiRequest.call(
this,
'GET',
'',
{},
qs,
'https://www.googleapis.com/drive/v3/files',
);
return {
results: res.files.map((sheet: IDataObject) => ({
name: sheet.name as string,
value: sheet.id as string,
url: sheet.webViewLink as string,
})),
paginationToken: res.nextPageToken,
};
}
export async function sheetsSearch(
this: ILoadOptionsFunctions,
_filter?: string,
): Promise<INodeListSearchResult> {
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
if (!documentId) return { results: [] };
const { mode, value } = documentId;
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
const query = {
fields: 'sheets.properties',
};
const responseData = await apiRequest.call(
this,
'GET',
`/v4/spreadsheets/${spreadsheetId}`,
{},
query,
);
if (responseData === undefined) {
throw new NodeOperationError(this.getNode(), 'No data got returned');
}
const returnData: INodeListSearchItems[] = [];
for (const sheet of responseData.sheets!) {
if (sheet.properties!.sheetType !== 'GRID') {
continue;
}
returnData.push({
name: sheet.properties!.title as string,
value: (sheet.properties!.sheetId as number) || 'gid=0',
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit#gid=${sheet.properties!.sheetId}`,
});
}
return { results: returnData };
}
@@ -0,0 +1,114 @@
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { GoogleSheet } from '../helpers/GoogleSheet';
import type { ResourceLocator } from '../helpers/GoogleSheets.types';
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
export async function getSheets(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
if (!documentId) return [];
const { mode, value } = documentId;
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
const sheet = new GoogleSheet(spreadsheetId, this);
const responseData = await sheet.spreadsheetGetSheets();
if (responseData === undefined) {
throw new NodeOperationError(this.getNode(), 'No data got returned');
}
const returnData: INodePropertyOptions[] = [];
for (const entry of responseData.sheets!) {
if (entry.properties!.sheetType !== 'GRID') {
continue;
}
returnData.push({
name: entry.properties!.title as string,
value: entry.properties!.sheetId as unknown as string,
});
}
return returnData;
}
export async function getSheetHeaderRow(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
if (!documentId) return [];
const { mode, value } = documentId;
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
const sheet = new GoogleSheet(spreadsheetId, this);
const sheetWithinDocument = this.getNodeParameter('sheetName', undefined, {
extractValue: true,
}) as string;
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as {
mode: ResourceLocator;
};
const { title: sheetName } = await sheet.spreadsheetGetSheet(
this.getNode(),
sheetMode,
sheetWithinDocument,
);
const sheetData = await sheet.getData(`${sheetName}!1:1`, 'FORMATTED_VALUE');
if (sheetData === undefined) {
throw new NodeOperationError(this.getNode(), 'No data got returned');
}
const columns = sheet.testFilter(sheetData, 0, 0);
const returnData: INodePropertyOptions[] = [];
for (const column of columns) {
returnData.push({
name: column as unknown as string,
value: column as unknown as string,
});
}
return returnData;
}
export async function getSheetHeaderRowAndAddColumn(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData = await getSheetHeaderRow.call(this);
returnData.push({
name: 'New column ...',
value: 'newColumn',
});
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', 0) as string;
return returnData.filter((column) => column.value !== columnToMatchOn);
}
export async function getSheetHeaderRowWithGeneratedColumnNames(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData = await getSheetHeaderRow.call(this);
return returnData.map((column, i) => {
if (column.value !== '') return column;
const indexBasedValue = `col_${i + 1}`;
return {
name: indexBasedValue,
value: indexBasedValue,
};
});
}
export async function getSheetHeaderRowAndSkipEmpty(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData = await getSheetHeaderRow.call(this);
return returnData.filter((column) => column.value);
}
@@ -0,0 +1,81 @@
import type {
IDataObject,
ILoadOptionsFunctions,
ResourceMapperField,
ResourceMapperFields,
} from 'n8n-workflow';
import { GoogleSheet } from '../helpers/GoogleSheet';
import { ROW_NUMBER, type ResourceLocator } from '../helpers/GoogleSheets.types';
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
export async function getMappingColumns(
this: ILoadOptionsFunctions,
): Promise<ResourceMapperFields> {
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
if (!documentId) return { fields: [] };
const { mode, value } = documentId;
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
const sheet = new GoogleSheet(spreadsheetId, this);
const sheetWithinDocument = this.getNodeParameter('sheetName', undefined, {
extractValue: true,
}) as string;
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as { mode: ResourceLocator };
const { title: sheetName } = await sheet.spreadsheetGetSheet(
this.getNode(),
sheetMode,
sheetWithinDocument,
);
const locationDefine = this.getNodeParameter(
'options.locationDefine.values',
0,
{},
) as IDataObject;
let columnNamesRow = 1;
if (locationDefine.headerRow) {
columnNamesRow = locationDefine.headerRow as number;
}
const sheetData = await sheet.getData(
`${sheetName}!${columnNamesRow}:${columnNamesRow}`,
'FORMATTED_VALUE',
);
const columns = sheet.testFilter(sheetData || [], 0, 0).filter((col) => col !== '');
const fields: ResourceMapperField[] = columns.map((col) => ({
id: col,
displayName: col,
required: false,
defaultMatch: col === 'id',
display: true,
type: 'string',
canBeUsedToMatch: true,
}));
const operation = this.getNodeParameter('operation', 0) as string;
if (operation === 'update') {
fields.push({
id: ROW_NUMBER,
displayName: ROW_NUMBER,
required: false,
defaultMatch: false,
display: true,
type: 'number',
canBeUsedToMatch: true,
readOnly: true,
removed: true,
});
}
return { fields };
}
@@ -0,0 +1,103 @@
import set from 'lodash/set';
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
IPollFunctions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { getGoogleAccessToken } from '../../../GenericFunctions';
export async function apiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
resource: string,
body: IDataObject = {},
qs: IDataObject = {},
uri?: string,
headers: IDataObject = {},
option: IDataObject = {},
) {
const authenticationMethod = this.getNodeParameter(
'authentication',
0,
'serviceAccount',
) as string;
const options: IRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri || `https://sheets.googleapis.com${resource}`,
json: true,
...option,
};
try {
if (Object.keys(headers).length !== 0) {
options.headers = Object.assign({}, options.headers, headers);
}
if (Object.keys(body).length === 0) {
delete options.body;
}
if (authenticationMethod === 'serviceAccount') {
const credentials = await this.getCredentials('googleApi');
const { access_token } = await getGoogleAccessToken.call(this, credentials, 'sheetV2');
options.headers!.Authorization = `Bearer ${access_token}`;
return await this.helpers.request(options);
} else if (authenticationMethod === 'triggerOAuth2') {
return await this.helpers.requestOAuth2.call(this, 'googleSheetsTriggerOAuth2Api', options);
} else {
return await this.helpers.requestOAuth2.call(this, 'googleSheetsOAuth2Api', options);
}
} catch (error) {
if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') {
error.statusCode = '401';
}
if (error instanceof NodeApiError) {
if (error.message.includes('PERMISSION_DENIED')) {
const details = error.description ? ` Details of the error: ${error.description}.` : '';
const description = `Please check that the account you're using has the right permissions. (If you're trying to modify the sheet, you'll need edit access.)${details}`;
set(error, 'description', description);
}
throw error;
}
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
export async function apiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: IDataObject = {},
query: IDataObject = {},
uri?: string,
) {
const returnData: IDataObject[] = [];
let responseData;
query.maxResults = 100;
const url = uri ? uri : `https://sheets.googleapis.com${method}`;
do {
responseData = await apiRequest.call(this, method, endpoint, body, query, url);
query.pageToken = responseData.nextPageToken;
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
} while (responseData.nextPageToken !== undefined && responseData.nextPageToken !== '');
return returnData;
}