feat(Google Sheets Node): Overhaul of node

This commit is contained in:
Jonathan Bennetts
2022-11-15 13:57:07 +00:00
committed by GitHub
parent 6eee155ecb
commit d96d6f11db
30 changed files with 5301 additions and 1394 deletions

View File

@@ -0,0 +1,199 @@
import { 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';
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',
value: 'append',
description: 'Append data to a sheet',
action: 'Append data to a sheet',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-option-name-wrong-for-upsert
name: 'Append or Update',
value: 'appendOrUpdate',
description: 'Append a new row or update the current one if it already exists (upsert)',
action: 'Append or update a sheet',
},
{
name: 'Clear',
value: 'clear',
description: 'Clear data from a sheet',
action: 'Clear a sheet',
},
{
name: 'Create',
value: 'create',
description: 'Create a new sheet',
action: 'Create a sheet',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete columns and rows from a sheet',
action: 'Delete a sheet',
},
{
name: 'Read Rows',
value: 'read',
description: 'Read all rows in a sheet',
action: 'Read all rows',
},
{
name: 'Remove',
value: 'remove',
description: 'Remove a sheet',
action: 'Remove a sheet',
},
{
name: 'Update',
value: 'update',
description: 'Update rows in a sheet',
action: 'Update a sheet',
},
],
default: 'read',
},
{
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:
'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)',
},
validation: [
{
type: 'regex',
properties: {
regex:
'https:\\/\\/(?:drive|docs)\\.google.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)',
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,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'sheetsSearch',
searchable: false,
},
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
extractValue: {
type: 'regex',
regex: `https:\\/\\/docs\\.google\\.com\/spreadsheets\\/d\\/[0-9a-zA-Z\\-_]+\\/edit\\#gid=([0-9]+)`,
},
validation: [
{
type: 'regex',
properties: {
regex: `https:\\/\\/docs\\.google\\.com\/spreadsheets\\/d\\/[0-9a-zA-Z\\-_]+\\/edit\\#gid=([0-9]+)`,
errorMessage: 'Not a valid Sheet URL',
},
},
],
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '[0-9]{2,}',
errorMessage: 'Not a valid Sheet ID',
},
},
],
},
],
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,
];

View File

@@ -0,0 +1,188 @@
import { IExecuteFunctions } from 'n8n-core';
import { SheetProperties, ValueInputOption } from '../../helpers/GoogleSheets.types';
import { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { GoogleSheet } from '../../helpers/GoogleSheet';
import { autoMapInputData, mapFields, untilSheetSelected } from '../../helpers/GoogleSheets.utils';
import { cellFormat, handlingExtraData } from './commonDescription';
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'],
},
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 a 'set' node before this node to change it if required.)",
name: 'autoMapNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['append'],
dataMode: ['autoMapInputData'],
},
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'],
},
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-examples/expressions/">expression</a>',
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
},
default: '',
},
{
displayName: 'Field Value',
name: 'fieldValue',
type: 'string',
default: '',
},
],
},
],
},
{
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,
],
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const dataMode = this.getNodeParameter('dataMode', 0) as string;
if (!items.length || dataMode === 'nothing') return [];
const options = this.getNodeParameter('options', 0, {}) as IDataObject;
const locationDefine = ((options.locationDefine as IDataObject) || {}).values as IDataObject;
let headerRow = 1;
if (locationDefine && locationDefine.headerRow) {
headerRow = locationDefine.headerRow as number;
}
let setData: IDataObject[] = [];
if (dataMode === 'autoMapInputData') {
setData = await autoMapInputData.call(this, sheetName, sheet, items, options);
} else {
setData = mapFields.call(this, items.length);
}
await sheet.appendSheetData(
setData,
sheetName,
headerRow,
(options.cellFormat as ValueInputOption) || 'RAW',
false,
);
return items;
}

View File

@@ -0,0 +1,315 @@
import { IExecuteFunctions } from 'n8n-core';
import { ISheetUpdateData, SheetProperties } from '../../helpers/GoogleSheets.types';
import { IDataObject, INodeExecutionData, NodeOperationError } from 'n8n-workflow';
import { GoogleSheet } from '../../helpers/GoogleSheet';
import { ValueInputOption, ValueRenderOption } from '../../helpers/GoogleSheets.types';
import { untilSheetSelected } from '../../helpers/GoogleSheets.utils';
import { cellFormat, handlingExtraData, locationDefine } from './commonDescription';
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'],
},
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-examples/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'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Value of Column to Match On',
name: 'valueToMatchOn',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
dataMode: ['defineBelow'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Values to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
dataMode: ['defineBelow'],
},
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-examples/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: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['appendOrUpdate'],
},
hide: {
...untilSheetSelected,
},
},
options: [...cellFormat, ...locationDefine, ...handlingExtraData],
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const valueInputMode = this.getNodeParameter('options.cellFormat', 0, 'RAW') as ValueInputOption;
const range = `${sheetName}!A:Z`;
const options = this.getNodeParameter('options', 0, {}) as IDataObject;
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
const locationDefine = ((options.locationDefine as IDataObject) || {}).values as IDataObject;
let headerRow = 0;
let firstDataRow = 1;
if (locationDefine) {
if (locationDefine.headerRow) {
headerRow = parseInt(locationDefine.headerRow as string, 10) - 1;
}
if (locationDefine.firstDataRow) {
firstDataRow = parseInt(locationDefine.firstDataRow as string, 10) - 1;
}
}
let columnNames: string[] = [];
const sheetData = await sheet.getData(sheetName, 'FORMATTED_VALUE');
if (sheetData === undefined || sheetData[headerRow] === undefined) {
throw new NodeOperationError(
this.getNode(),
`Could not retrieve the column names from row ${headerRow + 1}`,
);
}
columnNames = sheetData[headerRow];
const newColumns = new Set<string>();
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', 0) as string;
const keyIndex = columnNames.indexOf(columnToMatchOn);
const columnValues = await sheet.getColumnValues(
range,
keyIndex,
firstDataRow,
valueRenderMode,
sheetData,
);
const updateData: ISheetUpdateData[] = [];
const appendData: IDataObject[] = [];
for (let i = 0; i < items.length; i++) {
const dataMode = this.getNodeParameter('dataMode', i) as
| 'defineBelow'
| 'autoMapInputData'
| 'nothing';
if (dataMode === 'nothing') continue;
const data: IDataObject[] = [];
if (dataMode === 'autoMapInputData') {
const handlingExtraData = (options.handlingExtraData as string) || 'insertInNewColumn';
if (handlingExtraData === 'ignoreIt') {
data.push(items[i].json);
}
if (handlingExtraData === 'error') {
Object.keys(items[i].json).forEach((key) => {
if (columnNames.includes(key) === false) {
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'.`,
});
}
});
data.push(items[i].json);
}
if (handlingExtraData === 'insertInNewColumn') {
Object.keys(items[i].json).forEach((key) => {
if (columnNames.includes(key) === false) {
newColumns.add(key);
}
});
data.push(items[i].json);
}
} else {
const valueToMatchOn = this.getNodeParameter('valueToMatchOn', i) as string;
const fields = (this.getNodeParameter('fieldsUi.values', i, {}) as IDataObject[]).reduce(
(acc, entry) => {
if (entry.column === 'newColumn') {
const columnName = entry.columnName as string;
if (columnNames.includes(columnName) === false) {
newColumns.add(columnName);
}
acc[columnName] = entry.fieldValue as string;
} else {
acc[entry.column as string] = entry.fieldValue as string;
}
return acc;
},
{} as IDataObject,
);
fields[columnToMatchOn] = valueToMatchOn;
data.push(fields);
}
if (newColumns.size) {
await sheet.updateRows(
sheetName,
[columnNames.concat([...newColumns])],
(options.cellFormat as ValueInputOption) || 'RAW',
headerRow + 1,
);
}
const preparedData = await sheet.prepareDataForUpdateOrUpsert(
data,
columnToMatchOn,
range,
headerRow,
firstDataRow,
valueRenderMode,
true,
[columnNames.concat([...newColumns])],
columnValues,
);
updateData.push(...preparedData.updateData);
appendData.push(...preparedData.appendData);
}
if (updateData.length) {
await sheet.batchUpdate(updateData, valueInputMode);
}
if (appendData.length) {
const lastRow = sheetData.length + 1;
await sheet.appendSheetData(
appendData,
range,
headerRow + 1,
valueInputMode,
false,
[columnNames.concat([...newColumns])],
lastRow,
);
}
return items;
}

View File

@@ -0,0 +1,210 @@
import { IExecuteFunctions } from 'n8n-core';
import { INodeExecutionData } from 'n8n-workflow';
import { SheetProperties } from '../../helpers/GoogleSheets.types';
import { GoogleSheet } from '../../helpers/GoogleSheet';
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;
}

View File

@@ -0,0 +1,274 @@
import { 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 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.',
hint: 'From start of range. First row is row 1',
displayOptions: {
show: {
rangeDefinition: ['specifyRange'],
},
},
},
{
displayName: 'First Data Row',
name: 'firstDataRow',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 2,
description:
'Index of the first row which contains the actual data and not the keys. Starts with 1.',
hint: 'From start of range. First row is row 1',
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 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.',
hint: 'From start of range. First row is row 1',
},
{
displayName: 'First Data Row',
name: 'firstDataRow',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 2,
description:
'Index of the first row which contains the actual data and not the keys. Starts with 1.',
hint: 'From start of range. First row is row 1',
},
],
},
],
},
];
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 n8n format',
value: 'RAW',
description: 'Cells have the same types as the input data',
},
{
// 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',
},
],
default: 'RAW',
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",
},
];

View File

@@ -0,0 +1,127 @@
import { IExecuteFunctions } from 'n8n-core';
import { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { SheetProperties } from '../../helpers/GoogleSheets.types';
import { apiRequest } from '../../transport';
import { GoogleSheet } from '../../helpers/GoogleSheet';
import { getExistingSheetNames, hexToRgb } from '../../helpers/GoogleSheets.utils';
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: IDataObject[] = [];
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, {}) as IDataObject;
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);
returnData.push(responseData);
}
return this.helpers.returnJsonArray(returnData);
}

View File

@@ -0,0 +1,169 @@
import { IExecuteFunctions } from 'n8n-core';
import { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { SheetProperties } from '../../helpers/GoogleSheets.types';
import { GoogleSheet } from '../../helpers/GoogleSheet';
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);
}
return this.helpers.returnJsonArray({ success: true });
}

View File

@@ -0,0 +1,168 @@
import { IExecuteFunctions } from 'n8n-core';
import { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { GoogleSheet } from '../../helpers/GoogleSheet';
import {
getRangeString,
prepareSheetData,
untilSheetSelected,
} from '../../helpers/GoogleSheets.utils';
import { ILookupValues, SheetProperties } from '../../helpers/GoogleSheets.types';
import { dataLocationOnSheet, outputFormatting } from './commonDescription';
import {
RangeDetectionOptions,
SheetRangeData,
ValueRenderOption,
} from '../../helpers/GoogleSheets.types';
export const description: SheetProperties = [
{
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-examples/expressions/">expression</a>',
},
{
displayName: 'Value',
name: 'lookupValue',
type: 'string',
default: '',
hint: 'The column must have this value to be matched',
},
],
},
],
displayOptions: {
show: {
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: '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',
},
],
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const options = this.getNodeParameter('options', 0, {}) as IDataObject;
const outputFormatting =
(((options.outputFormatting as IDataObject) || {}).values as IDataObject) || {};
const dataLocationOnSheetOptions =
(((options.dataLocationOnSheet as IDataObject) || {}).values as RangeDetectionOptions) || {};
if (dataLocationOnSheetOptions.rangeDefinition === undefined) {
dataLocationOnSheetOptions.rangeDefinition = 'detectAutomatically';
}
const range = getRangeString(sheetName, dataLocationOnSheetOptions);
const valueRenderMode = (outputFormatting.general || 'UNFORMATTED_VALUE') as ValueRenderOption;
const dateTimeRenderOption = (outputFormatting.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, firstDataRow } = prepareSheetData(sheetData, dataLocationOnSheetOptions);
let returnData = [];
const lookupValues = this.getNodeParameter('filtersUI.values', 0, []) as ILookupValues[];
if (lookupValues.length) {
const returnAllMatches = options.returnAllMatches === 'returnAllMatches' ? true : false;
const items = this.getInputData();
for (let i = 1; i < items.length; i++) {
const itemLookupValues = this.getNodeParameter('filtersUI.values', i, []) as ILookupValues[];
if (itemLookupValues.length) {
lookupValues.push(...itemLookupValues);
}
}
returnData = await sheet.lookupValues(
data as string[][],
headerRow,
firstDataRow,
lookupValues,
returnAllMatches,
);
} else {
returnData = sheet.structureArrayDataByColumn(data as string[][], headerRow, firstDataRow);
}
return this.helpers.returnJsonArray(returnData);
}

View File

@@ -0,0 +1,36 @@
import { IExecuteFunctions } from 'n8n-core';
import { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { apiRequest } from '../../transport';
import { GoogleSheet } from '../../helpers/GoogleSheet';
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const returnData: IDataObject[] = [];
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
const [spreadsheetId, sheetWithinDocument] = sheetName.split('||');
const requests = [
{
deleteSheet: {
sheetId: sheetWithinDocument,
},
},
];
let responseData;
responseData = await apiRequest.call(
this,
'POST',
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
{ requests },
);
delete responseData.replies;
returnData.push(responseData);
}
return this.helpers.returnJsonArray(returnData);
}

View File

@@ -0,0 +1,302 @@
import { IExecuteFunctions } from 'n8n-core';
import { ISheetUpdateData, SheetProperties } from '../../helpers/GoogleSheets.types';
import { IDataObject, INodeExecutionData, NodeOperationError } from 'n8n-workflow';
import { GoogleSheet } from '../../helpers/GoogleSheet';
import { ValueInputOption, ValueRenderOption } from '../../helpers/GoogleSheets.types';
import { untilSheetSelected } from '../../helpers/GoogleSheets.utils';
import { cellFormat, handlingExtraData, locationDefine } from './commonDescription';
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'],
},
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-examples/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'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Value of Column to Match On',
name: 'valueToMatchOn',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
dataMode: ['defineBelow'],
},
hide: {
...untilSheetSelected,
},
},
},
{
displayName: 'Values to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
dataMode: ['defineBelow'],
},
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-examples/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: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update'],
},
hide: {
...untilSheetSelected,
},
},
options: [...cellFormat, ...locationDefine, ...handlingExtraData],
},
];
export async function execute(
this: IExecuteFunctions,
sheet: GoogleSheet,
sheetName: string,
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const valueInputMode = this.getNodeParameter('options.cellFormat', 0, 'RAW') as ValueInputOption;
const range = `${sheetName}!A:Z`;
const options = this.getNodeParameter('options', 0, {}) as IDataObject;
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
const locationDefine = ((options.locationDefine as IDataObject) || {}).values as IDataObject;
let headerRow = 0;
let firstDataRow = 1;
if (locationDefine) {
if (locationDefine.headerRow) {
headerRow = parseInt(locationDefine.headerRow as string, 10) - 1;
}
if (locationDefine.firstDataRow) {
firstDataRow = parseInt(locationDefine.firstDataRow as string, 10) - 1;
}
}
let columnNames: string[] = [];
const sheetData = await sheet.getData(sheetName, 'FORMATTED_VALUE');
if (sheetData === undefined || sheetData[headerRow] === undefined) {
throw new NodeOperationError(
this.getNode(),
`Could not retrieve the column names from row ${headerRow + 1}`,
);
}
columnNames = sheetData[headerRow];
const newColumns = new Set<string>();
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', 0) as string;
const keyIndex = columnNames.indexOf(columnToMatchOn);
const columnValues = await sheet.getColumnValues(
range,
keyIndex,
firstDataRow,
valueRenderMode,
sheetData,
);
const updateData: ISheetUpdateData[] = [];
for (let i = 0; i < items.length; i++) {
const dataMode = this.getNodeParameter('dataMode', i) as
| 'defineBelow'
| 'autoMapInputData'
| 'nothing';
if (dataMode === 'nothing') continue;
const data: IDataObject[] = [];
if (dataMode === 'autoMapInputData') {
const handlingExtraData = (options.handlingExtraData as string) || 'insertInNewColumn';
if (handlingExtraData === 'ignoreIt') {
data.push(items[i].json);
}
if (handlingExtraData === 'error') {
Object.keys(items[i].json).forEach((key) => {
if (columnNames.includes(key) === false) {
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'.`,
});
}
});
data.push(items[i].json);
}
if (handlingExtraData === 'insertInNewColumn') {
Object.keys(items[i].json).forEach((key) => {
if (columnNames.includes(key) === false) {
newColumns.add(key);
}
});
data.push(items[i].json);
}
} else {
const valueToMatchOn = this.getNodeParameter('valueToMatchOn', i) as string;
const fields = (this.getNodeParameter('fieldsUi.values', i, {}) as IDataObject[]).reduce(
(acc, entry) => {
if (entry.column === 'newColumn') {
const columnName = entry.columnName as string;
if (columnNames.includes(columnName) === false) {
newColumns.add(columnName);
}
acc[columnName] = entry.fieldValue as string;
} else {
acc[entry.column as string] = entry.fieldValue as string;
}
return acc;
},
{} as IDataObject,
);
fields[columnToMatchOn] = valueToMatchOn;
data.push(fields);
}
if (newColumns.size) {
await sheet.updateRows(
sheetName,
[columnNames.concat([...newColumns])],
(options.cellFormat as ValueInputOption) || 'RAW',
headerRow + 1,
);
}
const preparedData = await sheet.prepareDataForUpdateOrUpsert(
data,
columnToMatchOn,
range,
headerRow,
firstDataRow,
valueRenderMode,
false,
[columnNames.concat([...newColumns])],
columnValues,
);
updateData.push(...preparedData.updateData);
}
if (updateData.length) {
await sheet.batchUpdate(updateData, valueInputMode);
}
return items;
}