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,25 @@
import { IExecuteFunctions } from 'n8n-core';
import { INodeType, INodeTypeBaseDescription, INodeTypeDescription } from 'n8n-workflow';
import { versionDescription } from './actions/versionDescription';
import { credentialTest, listSearch, loadOptions } from './methods';
import { router } from './actions/router';
export class GoogleSheetsV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
methods = {
loadOptions,
credentialTest,
listSearch,
};
async execute(this: IExecuteFunctions) {
return await router.call(this);
}
}

View File

@@ -0,0 +1,68 @@
import { IExecuteFunctions } from 'n8n-core';
import { IDataObject, INodeExecutionData } from 'n8n-workflow';
import * as sheet from './sheet/Sheet.resource';
import * as spreadsheet from './spreadsheet/SpreadSheet.resource';
import { GoogleSheet } from '../helpers/GoogleSheet';
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
import { GoogleSheets, ResourceLocator } from '../helpers/GoogleSheets.types';
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const operationResult: INodeExecutionData[] = [];
try {
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const googleSheets = {
resource,
operation,
} as GoogleSheets;
if (googleSheets.resource === 'sheet') {
const { mode, value } = this.getNodeParameter('documentId', 0) as IDataObject;
const spreadsheetId = getSpreadsheetId(mode as ResourceLocator, value as string);
const googleSheet = new GoogleSheet(spreadsheetId, this);
let sheetWithinDocument = '';
if (operation !== 'create') {
sheetWithinDocument = this.getNodeParameter('sheetName', 0, undefined, {
extractValue: true,
}) as string;
}
if (sheetWithinDocument === 'gid=0') {
sheetWithinDocument = '0';
}
let sheetName = '';
switch (operation) {
case 'create':
sheetName = spreadsheetId;
break;
case 'delete':
sheetName = sheetWithinDocument;
break;
case 'remove':
sheetName = `${spreadsheetId}||${sheetWithinDocument}`;
break;
default:
sheetName = await googleSheet.spreadsheetGetSheetNameById(sheetWithinDocument);
}
operationResult.push(
...(await sheet[googleSheets.operation].execute.call(this, googleSheet, sheetName)),
);
} else if (googleSheets.resource === 'spreadsheet') {
operationResult.push(...(await spreadsheet[googleSheets.operation].execute.call(this)));
}
} catch (err) {
if (this.continueOnFail()) {
operationResult.push({ json: this.getInputData(0)[0].json, error: err });
} else {
throw err;
}
}
return [operationResult];
}

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

View File

@@ -0,0 +1,36 @@
import * as create from './create.operation';
import * as deleteSpreadsheet from './delete.operation';
import { INodeProperties } from 'n8n-workflow';
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 a spreadsheet',
},
{
name: 'Delete',
value: 'deleteSpreadsheet',
description: 'Delete a spreadsheet',
action: 'Delete a spreadsheet',
},
],
default: 'create',
},
...create.description,
...deleteSpreadsheet.description,
];

View File

@@ -0,0 +1,153 @@
import { IExecuteFunctions } from 'n8n-core';
import { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { 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: IDataObject[] = [];
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, {}) as IDataObject;
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);
returnData.push(response);
}
return this.helpers.returnJsonArray(returnData);
}

View File

@@ -0,0 +1,103 @@
import { IExecuteFunctions } from 'n8n-core';
import { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { SpreadSheetProperties } from '../../helpers/GoogleSheets.types';
import { apiRequest } from '../../transport';
export const description: SpreadSheetProperties = [
// {
// displayName: 'Spreadsheet ID',
// name: 'spreadsheetId',
// type: 'string',
// default: '',
// displayOptions: {
// show: {
// resource: ['spreadsheet'],
// operation: ['deleteSpreadsheet'],
// },
// },
// },
{
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: ['spreadsheet'],
operation: ['deleteSpreadsheet'],
},
},
},
];
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const returnData: IDataObject[] = [];
for (let i = 0; i < items.length; i++) {
// const spreadsheetId = this.getNodeParameter('spreadsheetId', i) as string;
const documentId = this.getNodeParameter('documentId', i, undefined, {
extractValue: true,
}) as string;
await apiRequest.call(
this,
'DELETE',
'',
{},
{},
`https://www.googleapis.com/drive/v3/files/${documentId}`,
);
returnData.push({ success: true });
}
return this.helpers.returnJsonArray(returnData);
}

View File

@@ -0,0 +1,79 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { INodeTypeDescription } from 'n8n-workflow';
import * as sheet from './sheet/Sheet.resource';
import * as spreadsheet from './spreadsheet/SpreadSheet.resource';
export const versionDescription: INodeTypeDescription = {
displayName: 'Google Sheets',
name: 'googleSheets',
icon: 'file:googleSheets.svg',
group: ['input', 'output'],
version: 2,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Read, update and write data to Google Sheets',
defaults: {
name: 'Google Sheets',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'googleApi',
required: true,
displayOptions: {
show: {
authentication: ['serviceAccount'],
},
},
testedBy: 'googleApiCredentialTest',
},
{
name: 'googleSheetsOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
properties: [
{
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',
},
{
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,
],
};

View File

@@ -0,0 +1,657 @@
import { IDataObject, NodeOperationError } from 'n8n-workflow';
import { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-core';
import { apiRequest } from '../transport';
import { utils as xlsxUtils } from 'xlsx';
import { get } from 'lodash';
import {
ILookupValues,
ISheetUpdateData,
SheetCellDecoded,
SheetRangeData,
SheetRangeDecoded,
ValueInputOption,
ValueRenderOption,
} from './GoogleSheets.types';
import { removeEmptyColumns } from './GoogleSheets.utils';
export class GoogleSheet {
id: string;
executeFunctions: IExecuteFunctions | ILoadOptionsFunctions;
constructor(spreadsheetId: string, executeFunctions: IExecuteFunctions | ILoadOptionsFunctions) {
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}`;
}
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 name of a sheet from a sheet id
*/
async spreadsheetGetSheetNameById(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: number } }) => item.properties.sheetId === +sheetId,
);
if (!foundItem || !foundItem.properties || !foundItem.properties.title) {
throw new Error(`Sheet with id ${sheetId} not found`);
}
return foundItem.properties.title;
}
/**
* 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;
}
/**
* Appends the cell values
*/
async appendData(
range: string,
data: string[][],
valueInputMode: ValueInputOption,
lastRow?: number,
) {
// const body = {
// range,
// values: data,
// };
// const query = {
// valueInputOption: valueInputMode,
// };
// const response = await apiRequest.call(
// this.executeFunctions,
// 'POST',
// `/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}:append`,
// body,
// query,
// );
const lastRowWithData =
lastRow ||
(((await this.getData(range, 'UNFORMATTED_VALUE')) as string[][]) || []).length + 1;
const response = await this.updateRows(
range,
data,
valueInputMode,
lastRowWithData,
data.length,
);
return response;
}
async updateRows(
sheetName: string,
data: string[][],
valueInputMode: ValueInputOption,
row: number,
rowsLength?: number,
) {
const [name, _sheetRange] = sheetName.split('!');
const range = `${name}!${row}:${rowsLength ? row + rowsLength - 1 : row}`;
const body = {
range,
values: data,
};
const query = {
valueInputOption: valueInputMode,
};
const 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(
data: SheetRangeData,
startRow: number,
columnKeys: string[],
addEmpty?: boolean,
): IDataObject[] {
const returnData = [];
for (let rowIndex = startRow; rowIndex < data.length; rowIndex++) {
const item: IDataObject = {};
for (let columnIndex = 0; columnIndex < data[rowIndex].length; columnIndex++) {
const key = columnKeys[columnIndex];
if (key) {
item[key] = data[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,
): 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);
}
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: IDataObject[],
range: string,
keyRowIndex: number,
valueInputMode: ValueInputOption,
usePathForKeyRow: boolean,
columnNamesList?: string[][],
lastRow?: number,
): Promise<string[][]> {
const data = await this.convertObjectArrayToSheetDataArray(
inputData,
range,
keyRowIndex,
usePathForKeyRow,
columnNamesList,
);
return this.appendData(range, data, valueInputMode, lastRow);
}
getColumnWithOffset(startColumn: string, offset: number): string {
const columnIndex = xlsxUtils.decode_col(startColumn) + offset;
return xlsxUtils.encode_col(columnIndex);
}
async getColumnValues(
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: IDataObject[],
indexKey: string,
range: string,
keyRowIndex: number,
dataStartRowIndex: number,
valueRenderMode: ValueRenderOption,
upsert = false,
columnNamesList?: string[][],
columnValuesList?: string[],
) {
const decodedRange = this.getDecodedSheetRange(range);
// prettier-ignore
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) {
throw new NodeOperationError(
this.executeFunctions.getNode(),
`Could not find column for key "${indexKey}"`,
);
}
const columnValues =
columnValuesList ||
(await this.getColumnValues(range, keyIndex, dataStartRowIndex, valueRenderMode));
const updateData: ISheetUpdateData[] = [];
const appendData: IDataObject[] = [];
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 = columnValues.indexOf(inputIndexKey);
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),
);
updateData.push({
range: `${decodedRange.name}!${columnToUpdate}${updateRowIndex}`,
values: [[item[name] as string]],
});
}
}
return { updateData, appendData };
}
/**
* Looks for a specific value in a column and if it gets found it returns the whole row
*
* @param {string[][]} inputData Data to 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: string[][],
keyRowIndex: number,
dataStartRowIndex: number,
lookupValues: ILookupValues[],
returnAllMatches?: boolean,
): 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}`);
}
// Standardise 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];
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) {
continue lookupLoop;
}
}
}
}
return this.convertSheetDataArrayToObjectArray(removeEmptyColumns(returnData), 1, keys, true);
}
private async convertObjectArrayToSheetDataArray(
inputData: IDataObject[],
range: string,
keyRowIndex: number,
usePathForKeyRow: boolean,
columnNamesList?: string[][],
): 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('');
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] };
}
}

View File

@@ -0,0 +1,85 @@
import { 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';
export enum ResourceLocatorUiNames {
id = 'By ID',
url = 'By URL',
list = 'From List',
}
export type SheetCellDecoded = {
cell?: string;
column?: string;
row?: number;
};
export type SheetRangeDecoded = {
nameWithRange: string;
name: string;
range: string;
start?: SheetCellDecoded;
end?: SheetCellDecoded;
};

View File

@@ -0,0 +1,299 @@
import { IExecuteFunctions } from 'n8n-core';
import {
IDataObject,
INodeExecutionData,
INodeListSearchItems,
INodePropertyOptions,
NodeOperationError,
} from 'n8n-workflow';
import { GoogleSheet } from './GoogleSheet';
import {
RangeDetectionOptions,
ResourceLocator,
ResourceLocatorUiNames,
ROW_NUMBER,
SheetRangeData,
ValueInputOption,
} from './GoogleSheets.types';
export const untilSheetSelected = { sheetName: [''] };
// Used to extract the ID from the URL
export function getSpreadsheetId(documentIdType: ResourceLocator, value: string): string {
if (!value) {
throw new Error(
`Can not get sheet '${ResourceLocatorUiNames[documentIdType]}' with a value of '${value}'`,
);
}
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;
}
// 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, (m, 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 emtyRowIndex = data.findIndex((row) => row.slice(baseLength).every((cell) => cell === ''));
if (emtyRowIndex === -1) {
return data;
}
return data.slice(0, emtyRowIndex);
}
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'),
);
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) {
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]);
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] || ''));
}
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 as string}` : sheetName;
}
return sheetName;
}
export async function getExistingSheetNames(sheet: GoogleSheet) {
const { sheets } = await sheet.spreadsheetGetSheets();
return ((sheets as IDataObject[]) || []).map(
(sheet) => ((sheet.properties as IDataObject) || {}).title,
);
}
export function mapFields(this: IExecuteFunctions, inputSize: number) {
const returnData: IDataObject[] = [];
for (let i = 0; i < inputSize; i++) {
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);
}
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) === false) {
newColumns.add(key);
}
});
if (item.json[ROW_NUMBER]) {
delete item.json[ROW_NUMBER];
}
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) === false) {
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 as string).toLowerCase();
const bName = (b.name as string).toLowerCase();
if (aName < bName) {
return -1;
}
if (aName > bName) {
return 1;
}
return 0;
});
return returnData;
}

View File

@@ -0,0 +1,35 @@
import {
ICredentialsDecrypted,
ICredentialTestFunctions,
INodeCredentialTestResult,
} from 'n8n-workflow';
import { getAccessToken, IGoogleAuthCredentials } from '../transport';
export async function googleApiCredentialTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
try {
const tokenRequest = await getAccessToken.call(
this,
credential.data! as unknown as IGoogleAuthCredentials,
);
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!',
};
}

View File

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

View File

@@ -0,0 +1,96 @@
import {
IDataObject,
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
NodeOperationError,
} from 'n8n-workflow';
import { ResourceLocator } from '../helpers/GoogleSheets.types';
import { getSpreadsheetId, sortLoadOptions } from '../helpers/GoogleSheets.utils';
import { apiRequest, apiRequestAllItems } from '../transport';
export async function spreadSheetsSearch(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
try {
const returnData: INodeListSearchItems[] = [];
const query: string[] = [];
if (filter) {
query.push(`name contains '${filter.replace("'", "\\'")}'`);
}
query.push("mimeType = 'application/vnd.google-apps.spreadsheet'");
const qs = {
pageSize: 50,
orderBy: 'modifiedTime desc',
fields: 'nextPageToken, files(id, name, webViewLink)',
q: query.join(' and '),
includeItemsFromAllDrives: true,
supportsAllDrives: true,
};
const sheets = await apiRequestAllItems.call(
this,
'files',
'GET',
'',
{},
qs,
'https://www.googleapis.com/drive/v3/files',
);
for (const sheet of sheets) {
returnData.push({
name: sheet.name as string,
value: sheet.id as string,
url: sheet.webViewLink as string,
});
}
return { results: sortLoadOptions(returnData) };
} catch (error) {
return { results: [] };
}
}
export async function sheetsSearch(
this: ILoadOptionsFunctions,
_filter?: string,
): Promise<INodeListSearchResult> {
try {
const { mode, value } = this.getNodeParameter('documentId', 0) as IDataObject;
const spreadsheetId = getSpreadsheetId(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',
//prettier-ignore
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit#gid=${sheet.properties!.sheetId}`,
});
}
return { results: returnData };
} catch (error) {
return { results: [] };
}
}

View File

@@ -0,0 +1,112 @@
import {
IDataObject,
ILoadOptionsFunctions,
INodePropertyOptions,
NodeOperationError,
} from 'n8n-workflow';
import { GoogleSheet } from '../helpers/GoogleSheet';
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
import { ResourceLocator } from '../helpers/GoogleSheets.types';
export async function getSheets(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
try {
const { mode, value } = this.getNodeParameter('documentId', 0) as IDataObject;
const spreadsheetId = getSpreadsheetId(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 sheet of responseData.sheets!) {
if (sheet.properties!.sheetType !== 'GRID') {
continue;
}
returnData.push({
name: sheet.properties!.title as string,
value: sheet.properties!.sheetId as unknown as string,
});
}
return returnData;
} catch (error) {
return [];
}
}
export async function getSheetHeaderRow(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
try {
const { mode, value } = this.getNodeParameter('documentId', 0) as IDataObject;
const spreadsheetId = getSpreadsheetId(mode as ResourceLocator, value as string);
const sheet = new GoogleSheet(spreadsheetId, this);
let sheetWithinDocument = this.getNodeParameter('sheetName', undefined, {
extractValue: true,
}) as string;
if (sheetWithinDocument === 'gid=0') {
sheetWithinDocument = '0';
}
const sheetName = await sheet.spreadsheetGetSheetNameById(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;
} catch (error) {
return [];
}
}
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);
}

View File

@@ -0,0 +1,156 @@
import { OptionsWithUri } from 'request';
import { IExecuteFunctions, IExecuteSingleFunctions, ILoadOptionsFunctions } from 'n8n-core';
import { ICredentialTestFunctions, IDataObject, NodeApiError } from 'n8n-workflow';
import moment from 'moment-timezone';
import jwt from 'jsonwebtoken';
export interface IGoogleAuthCredentials {
delegatedEmail?: string;
email: string;
inpersonate: boolean;
privateKey: string;
}
export async function apiRequest(
this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions,
method: string,
resource: string,
body: IDataObject = {},
qs: IDataObject = {},
uri?: string,
headers: IDataObject = {},
) {
const authenticationMethod = this.getNodeParameter(
'authentication',
0,
'serviceAccount',
) as string;
const options: OptionsWithUri = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri || `https://sheets.googleapis.com${resource}`,
json: true,
};
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 getAccessToken.call(
this,
credentials as unknown as IGoogleAuthCredentials,
);
options.headers!.Authorization = `Bearer ${access_token}`;
//@ts-ignore
return await this.helpers.request(options);
} else {
//@ts-ignore
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.message.includes('PERMISSION_DENIED')) {
const message = 'Missing permissions for Google Sheet';
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.)";
throw new NodeApiError(this.getNode(), error, { message, description });
}
throw new NodeApiError(this.getNode(), error);
}
}
export async function apiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
method: string,
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]);
} while (responseData['nextPageToken'] !== undefined && responseData['nextPageToken'] !== '');
return returnData;
}
export function getAccessToken(
this:
| IExecuteFunctions
| IExecuteSingleFunctions
| ILoadOptionsFunctions
| ICredentialTestFunctions,
credentials: IGoogleAuthCredentials,
): Promise<IDataObject> {
//https://developers.google.com/identity/protocols/oauth2/service-account#httprest
const scopes = [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive.metadata',
];
const now = moment().unix();
credentials.email = credentials.email.trim();
const privateKey = (credentials.privateKey as string).replace(/\\n/g, '\n').trim();
const signature = jwt.sign(
{
iss: credentials.email as string,
sub: credentials.delegatedEmail || (credentials.email as string),
scope: scopes.join(' '),
aud: `https://oauth2.googleapis.com/token`,
iat: now,
exp: now + 3600,
},
privateKey,
{
algorithm: 'RS256',
header: {
kid: privateKey,
typ: 'JWT',
alg: 'RS256',
},
},
);
const options: OptionsWithUri = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
form: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: signature,
},
uri: 'https://oauth2.googleapis.com/token',
json: true,
};
//@ts-ignore
return this.helpers.request(options);
}