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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 5301 additions and 1394 deletions

View file

@ -3,6 +3,7 @@ import { ICredentialType, INodeProperties } from 'n8n-workflow';
const scopes = [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive.metadata',
];
export class GoogleSheetsOAuth2Api implements ICredentialType {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,502 @@
import { IExecuteFunctions } from 'n8n-core';
import {
ICredentialsDecrypted,
ICredentialTestFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeCredentialTestResult,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
NodeOperationError,
} from 'n8n-workflow';
import {
GoogleSheet,
ILookupValues,
ISheetUpdateData,
IToDelete,
ValueInputOption,
ValueRenderOption,
} from './GoogleSheet';
import {
getAccessToken,
googleApiRequest,
hexToRgb,
IGoogleAuthCredentials,
} from './GenericFunctions';
import { versionDescription } from './versionDescription';
export class GoogleSheetsV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
methods = {
loadOptions: {
// Get all the sheets in a Spreadsheet
async getSheets(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const spreadsheetId = this.getCurrentNodeParameter('sheetId') 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;
},
},
credentialTest: {
async 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!',
};
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const operation = this.getNodeParameter('operation', 0) as string;
const resource = this.getNodeParameter('resource', 0) as string;
if (resource === 'sheet') {
const spreadsheetId = this.getNodeParameter('sheetId', 0) as string;
const sheet = new GoogleSheet(spreadsheetId, this);
let range = '';
if (!['create', 'delete', 'remove'].includes(operation)) {
range = this.getNodeParameter('range', 0) as string;
}
const options = this.getNodeParameter('options', 0, {}) as IDataObject;
const valueInputMode = (options.valueInputMode || 'RAW') as ValueInputOption;
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
if (operation === 'append') {
// ----------------------------------
// append
// ----------------------------------
try {
const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10);
const items = this.getInputData();
const setData: IDataObject[] = [];
items.forEach((item) => {
setData.push(item.json);
});
const usePathForKeyRow = (options.usePathForKeyRow || false) as boolean;
// Convert data into array format
const _data = await sheet.appendSheetData(
setData,
sheet.encodeRange(range),
keyRow,
valueInputMode,
usePathForKeyRow,
);
// TODO: Should add this data somewhere
// TODO: Should have something like add metadata which does not get passed through
return this.prepareOutputData(items);
} catch (error) {
if (this.continueOnFail()) {
return this.prepareOutputData([{ json: { error: error.message } }]);
}
throw error;
}
} else if (operation === 'clear') {
// ----------------------------------
// clear
// ----------------------------------
try {
await sheet.clearData(sheet.encodeRange(range));
const items = this.getInputData();
return this.prepareOutputData(items);
} catch (error) {
if (this.continueOnFail()) {
return this.prepareOutputData([{ json: { error: error.message } }]);
}
throw error;
}
} else if (operation === 'create') {
const returnData: IDataObject[] = [];
let responseData;
for (let i = 0; i < this.getInputData().length; i++) {
try {
const spreadsheetId = this.getNodeParameter('sheetId', i) as string;
const options = this.getNodeParameter('options', i, {}) as IDataObject;
const simple = this.getNodeParameter('simple', 0) as boolean;
const properties = { ...options };
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 googleApiRequest.call(
this,
'POST',
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
{ requests },
);
if (simple === true) {
Object.assign(responseData, responseData.replies[0].addSheet.properties);
delete responseData.replies;
}
returnData.push(responseData);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ error: error.message });
continue;
}
throw error;
}
}
return [this.helpers.returnJsonArray(returnData)];
} else if (operation === 'delete') {
// ----------------------------------
// delete
// ----------------------------------
try {
const requests: IDataObject[] = [];
const toDelete = this.getNodeParameter('toDelete', 0) as IToDelete;
const deletePropertyToDimensions: IDataObject = {
columns: 'COLUMNS',
rows: 'ROWS',
};
for (const propertyName of Object.keys(deletePropertyToDimensions)) {
if (toDelete[propertyName] !== undefined) {
toDelete[propertyName]!.forEach((range) => {
requests.push({
deleteDimension: {
range: {
sheetId: range.sheetId,
dimension: deletePropertyToDimensions[propertyName] as string,
startIndex: range.startIndex,
endIndex:
parseInt(range.startIndex.toString(), 10) +
parseInt(range.amount.toString(), 10),
},
},
});
});
}
}
const _data = await sheet.spreadsheetBatchUpdate(requests);
const items = this.getInputData();
return this.prepareOutputData(items);
} catch (error) {
if (this.continueOnFail()) {
return this.prepareOutputData([{ json: { error: error.message } }]);
}
throw error;
}
} else if (operation === 'lookup') {
// ----------------------------------
// lookup
// ----------------------------------
try {
const sheetData = await sheet.getData(sheet.encodeRange(range), valueRenderMode);
if (sheetData === undefined) {
return [];
}
const dataStartRow = parseInt(this.getNodeParameter('dataStartRow', 0) as string, 10);
const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10);
const items = this.getInputData();
const lookupValues: ILookupValues[] = [];
for (let i = 0; i < items.length; i++) {
lookupValues.push({
lookupColumn: this.getNodeParameter('lookupColumn', i) as string,
lookupValue: this.getNodeParameter('lookupValue', i) as string,
});
}
let returnData = await sheet.lookupValues(
sheetData,
keyRow,
dataStartRow,
lookupValues,
options.returnAllMatches as boolean | undefined,
);
if (returnData.length === 0 && options.continue && options.returnAllMatches) {
returnData = [{}];
} else if (
returnData.length === 1 &&
Object.keys(returnData[0]).length === 0 &&
!options.continue &&
!options.returnAllMatches
) {
returnData = [];
}
return [this.helpers.returnJsonArray(returnData)];
} catch (error) {
if (this.continueOnFail()) {
return [this.helpers.returnJsonArray({ error: error.message })];
}
throw error;
}
} else if (operation === 'read') {
// ----------------------------------
// read
// ----------------------------------
try {
const rawData = this.getNodeParameter('rawData', 0) as boolean;
const sheetData = await sheet.getData(sheet.encodeRange(range), valueRenderMode);
let returnData: IDataObject[];
if (!sheetData) {
returnData = [];
} else if (rawData === true) {
const dataProperty = this.getNodeParameter('dataProperty', 0) as string;
returnData = [
{
[dataProperty]: sheetData,
},
];
} else {
const dataStartRow = parseInt(this.getNodeParameter('dataStartRow', 0) as string, 10);
const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10);
returnData = sheet.structureArrayDataByColumn(sheetData, keyRow, dataStartRow);
}
if (returnData.length === 0 && options.continue) {
returnData = [{}];
}
return [this.helpers.returnJsonArray(returnData)];
} catch (error) {
if (this.continueOnFail()) {
return [this.helpers.returnJsonArray({ error: error.message })];
}
throw error;
}
} else if (operation === 'remove') {
const returnData: IDataObject[] = [];
let responseData;
for (let i = 0; i < this.getInputData().length; i++) {
try {
const sheetId = this.getNodeParameter('id', i) as string;
const spreadsheetId = this.getNodeParameter('sheetId', i) as string;
const requests = [
{
deleteSheet: {
sheetId,
},
},
];
responseData = await googleApiRequest.call(
this,
'POST',
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
{ requests },
);
delete responseData.replies;
returnData.push(responseData);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ error: error.message });
continue;
}
throw error;
}
}
return [this.helpers.returnJsonArray(returnData)];
} else if (operation === 'update' || operation === 'upsert') {
// ----------------------------------
// update/upsert
// ----------------------------------
const upsert = operation === 'upsert' ? true : false;
try {
const rawData = this.getNodeParameter('rawData', 0) as boolean;
const items = this.getInputData();
if (rawData === true) {
const dataProperty = this.getNodeParameter('dataProperty', 0) as string;
const updateData: ISheetUpdateData[] = [];
for (let i = 0; i < items.length; i++) {
updateData.push({
range,
values: items[i].json[dataProperty] as string[][],
});
}
const _data = await sheet.batchUpdate(updateData, valueInputMode);
} else {
const keyName = this.getNodeParameter('key', 0) as string;
const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10);
const dataStartRow = parseInt(this.getNodeParameter('dataStartRow', 0) as string, 10);
const setData: IDataObject[] = [];
items.forEach((item) => {
setData.push(item.json);
});
const _data = await sheet.updateSheetData(
setData,
keyName,
range,
keyRow,
dataStartRow,
valueInputMode,
valueRenderMode,
upsert,
);
}
// TODO: Should add this data somewhere
// TODO: Should have something like add metadata which does not get passed through
return this.prepareOutputData(items);
} catch (error) {
if (this.continueOnFail()) {
return this.prepareOutputData([{ json: { error: error.message } }]);
}
throw error;
}
}
}
if (resource === 'spreadsheet') {
const returnData: IDataObject[] = [];
let responseData;
if (operation === 'create') {
// ----------------------------------
// create
// ----------------------------------
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/create
for (let i = 0; i < this.getInputData().length; i++) {
try {
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 sheet of sheets) {
const properties = sheet.propertiesUi as IDataObject;
if (properties) {
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;
responseData = await googleApiRequest.call(this, 'POST', `/v4/spreadsheets`, body);
returnData.push(responseData);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ error: error.message });
continue;
}
throw error;
}
}
}
return [this.helpers.returnJsonArray(returnData)];
}
return [];
}
}

View file

@ -0,0 +1,880 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { INodeTypeDescription } from 'n8n-workflow';
export const versionDescription: INodeTypeDescription = {
displayName: 'Google Sheets ',
name: 'googleSheets',
icon: 'file:googleSheets.svg',
group: ['input', 'output'],
version: 1,
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',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'serviceAccount',
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Spreadsheet',
value: 'spreadsheet',
},
{
name: 'Sheet',
value: 'sheet',
},
],
default: 'sheet',
},
{
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',
},
{
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: 'Create or Update',
value: 'upsert',
description:
'Create a new record, or update the current one if it already exists (upsert)',
action: 'Create or update a sheet',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete columns and rows from a sheet',
action: 'Delete a sheet',
},
{
name: 'Lookup',
value: 'lookup',
description: 'Look up a specific column value and return the matching row',
action: 'Look up a column value in a sheet',
},
{
name: 'Read',
value: 'read',
description: 'Read data from a sheet',
action: 'Read a sheet',
},
{
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',
},
// ----------------------------------
// All
// ----------------------------------
{
displayName: 'Spreadsheet ID',
name: 'sheetId',
type: 'string',
displayOptions: {
show: {
resource: ['sheet'],
},
},
default: '',
required: true,
description:
'The ID of the Google Spreadsheet. Found as part of the sheet URL https://docs.google.com/spreadsheets/d/{ID}/.',
},
{
displayName: 'Range',
name: 'range',
type: 'string',
displayOptions: {
show: {
resource: ['sheet'],
},
hide: {
operation: ['create', 'delete', 'remove'],
},
},
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"',
},
// ----------------------------------
// Delete
// ----------------------------------
{
displayName: 'To Delete',
name: 'toDelete',
placeholder: 'Add Columns/Rows to delete',
description: 'Deletes columns and rows from a sheet',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['delete'],
},
},
default: {},
options: [
{
displayName: 'Columns',
name: 'columns',
values: [
{
displayName: 'Sheet Name or ID',
name: 'sheetId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getSheets',
},
options: [],
default: '',
required: true,
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
},
{
displayName: 'Start Index',
name: 'startIndex',
type: 'number',
typeOptions: {
minValue: 0,
},
default: 0,
description: 'The start index (0 based and inclusive) of column to delete',
},
{
displayName: 'Amount',
name: 'amount',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
description: 'Number of columns to delete',
},
],
},
{
displayName: 'Rows',
name: 'rows',
values: [
{
displayName: 'Sheet Name or ID',
name: 'sheetId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getSheets',
},
options: [],
default: '',
required: true,
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
},
{
displayName: 'Start Index',
name: 'startIndex',
type: 'number',
typeOptions: {
minValue: 0,
},
default: 0,
description: 'The start index (0 based and inclusive) of row to delete',
},
{
displayName: 'Amount',
name: 'amount',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
description: 'Number of rows to delete',
},
],
},
],
},
// ----------------------------------
// Read
// ----------------------------------
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['read'],
},
},
default: false,
description:
'Whether the data should be returned RAW instead of parsed into keys according to their header',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['read'],
rawData: [true],
},
},
description: 'The name of the property into which to write the RAW data',
},
// ----------------------------------
// Update
// ----------------------------------
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update', 'upsert'],
},
},
default: false,
description: 'Whether the data supplied is RAW instead of parsed into keys',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update', 'upsert'],
rawData: [true],
},
},
description: 'The name of the property from which to read the RAW data',
},
// ----------------------------------
// Read & Update & lookupColumn
// ----------------------------------
{
displayName: 'Data Start Row',
name: 'dataStartRow',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
displayOptions: {
show: {
resource: ['sheet'],
},
hide: {
operation: ['append', 'create', 'clear', 'delete', 'remove'],
rawData: [true],
},
},
description:
'Index of the first row which contains the actual data and not the keys. Starts with 0.',
},
// ----------------------------------
// Mixed
// ----------------------------------
{
displayName: 'Key Row',
name: 'keyRow',
type: 'number',
typeOptions: {
minValue: 0,
},
displayOptions: {
show: {
resource: ['sheet'],
},
hide: {
operation: ['clear', 'create', 'delete', 'remove'],
rawData: [true],
},
},
default: 0,
description:
'Index of the row which contains the keys. Starts at 0. The incoming node data is matched to the keys for assignment. The matching is case sensitive.',
},
// ----------------------------------
// lookup
// ----------------------------------
{
displayName: 'Lookup Column',
name: 'lookupColumn',
type: 'string',
default: '',
placeholder: 'Email',
required: true,
displayOptions: {
show: {
resource: ['sheet'],
operation: ['lookup'],
},
},
description: 'The name of the column in which to look for value',
},
{
displayName: 'Lookup Value',
name: 'lookupValue',
type: 'string',
default: '',
placeholder: 'frank@example.com',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['lookup'],
},
},
description: 'The value to look for in column',
},
// ----------------------------------
// Update
// ----------------------------------
{
displayName: 'Key',
name: 'key',
type: 'string',
default: 'id',
displayOptions: {
show: {
resource: ['sheet'],
operation: ['update', 'upsert'],
rawData: [false],
},
},
description: 'The name of the key to identify which data should be updated in the sheet',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['append', 'lookup', 'read', 'update', 'upsert'],
},
},
options: [
{
displayName: 'Continue If Empty',
name: 'continue',
type: 'boolean',
default: false,
displayOptions: {
show: {
'/operation': ['lookup', 'read'],
},
},
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'By default, the workflow stops executing if the lookup/read does not return values',
},
{
displayName: 'Return All Matches',
name: 'returnAllMatches',
type: 'boolean',
default: false,
displayOptions: {
show: {
'/operation': ['lookup'],
},
},
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'By default only the first result gets returned. If options gets set all found matches get returned.',
},
{
displayName: 'Use Header Names as JSON Paths',
name: 'usePathForKeyRow',
type: 'boolean',
default: false,
displayOptions: {
show: {
'/operation': ['append'],
},
},
description:
'Whether you want to match the headers as path, for example, the row header "category.name" will match the "category" object and get the field "name" from it. By default "category.name" will match with the field with exact name, not nested object.',
},
{
displayName: 'Value Input Mode',
name: 'valueInputMode',
type: 'options',
displayOptions: {
show: {
'/operation': ['append', 'update', 'upsert'],
},
},
options: [
{
name: 'RAW',
value: 'RAW',
description: 'The values will not be parsed and will be stored as-is',
},
{
name: 'User Entered',
value: 'USER_ENTERED',
description:
'The values will be parsed as if the user typed them into the UI. Numbers will stay as numbers, but strings may be converted to numbers, dates, etc. following the same rules that are applied when entering text into a cell via the Google Sheets UI.',
},
],
default: 'RAW',
description: 'Determines how data should be interpreted',
},
{
displayName: 'Value Render Mode',
name: 'valueRenderMode',
type: 'options',
displayOptions: {
show: {
'/operation': ['lookup', 'read'],
},
},
options: [
{
name: 'Formatted Value',
value: 'FORMATTED_VALUE',
description:
"Values will be calculated & formatted in the reply according to the cell's formatting.Formatting is based on the spreadsheet's locale, not the requesting user's locale.For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return \"$1.23\"",
},
{
name: 'Formula',
value: 'FORMULA',
description:
'Values will not be calculated. The reply will include the formulas. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return "=A1".',
},
{
name: 'Unformatted Value',
value: 'UNFORMATTED_VALUE',
description:
'Values will be calculated, but not formatted in the reply. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return the number 1.23.',
},
],
default: 'UNFORMATTED_VALUE',
description: 'Determines how values should be rendered in the output',
},
{
displayName: 'Value Render Mode',
name: 'valueRenderMode',
type: 'options',
displayOptions: {
show: {
'/operation': ['update', 'upsert'],
'/rawData': [false],
},
},
options: [
{
name: 'Formatted Value',
value: 'FORMATTED_VALUE',
description:
"Values will be calculated & formatted in the reply according to the cell's formatting.Formatting is based on the spreadsheet's locale, not the requesting user's locale. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return \"$1.23\".",
},
{
name: 'Formula',
value: 'FORMULA',
description:
'Values will not be calculated. The reply will include the formulas. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return "=A1".',
},
{
name: 'Unformatted Value',
value: 'UNFORMATTED_VALUE',
description:
'Values will be calculated, but not formatted in the reply. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return the number 1.23.',
},
],
default: 'UNFORMATTED_VALUE',
description: 'Determines how values should be rendered in the output',
},
],
},
{
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',
},
],
default: 'create',
},
// ----------------------------------
// spreadsheet:create
// ----------------------------------
{
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: {},
displayOptions: {
show: {
resource: ['spreadsheet'],
operation: ['create'],
},
},
options: [
{
name: 'sheetValues',
displayName: 'Sheet',
values: [
{
displayName: 'Sheet Properties',
name: 'propertiesUi',
placeholder: 'Add Property',
type: 'collection',
default: {},
options: [
{
displayName: 'Hidden',
name: 'hidden',
type: 'boolean',
default: false,
description: 'Whether the Sheet should be hidden in the UI',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
description: 'Title of the property to 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',
},
],
},
// ----------------------------------
// sheet:create
// ----------------------------------
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
default: true,
displayOptions: {
show: {
resource: ['sheet'],
operation: ['create'],
},
},
description: 'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
resource: ['sheet'],
operation: ['create'],
},
},
options: [
{
displayName: 'Grid Properties',
name: 'gridProperties',
type: 'collection',
placeholder: 'Add Property',
default: {},
options: [
{
displayName: 'Column Count',
name: 'columnCount',
type: 'number',
default: 0,
description: 'The number of columns in the grid',
},
{
displayName: 'Column Group Control After',
name: 'columnGroupControlAfter',
type: 'boolean',
default: false,
description: 'Whether the column grouping control toggle is shown after the group',
},
{
displayName: 'Frozen Column Count',
name: 'frozenColumnCount',
type: 'number',
default: 0,
description: 'The number of columns that are frozen in the grid',
},
{
displayName: 'Frozen Row Count',
name: 'frozenRowCount',
type: 'number',
default: 0,
description: 'The number of rows that are frozen in the grid',
},
{
displayName: 'Hide Gridlines',
name: 'hideGridlines',
type: 'boolean',
default: false,
description: "Whether the grid isn't showing gridlines in the UI",
},
{
displayName: 'Row Count',
name: 'rowCount',
type: 'number',
default: 0,
description: 'The number of rows in the grid',
},
{
displayName: 'Row Group Control After',
name: 'rowGroupControlAfter',
type: 'boolean',
default: false,
description: 'Whether the row grouping control toggle is shown after the group',
},
],
description: 'The type of the sheet',
},
{
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',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
description: 'The Sheet name',
},
],
},
// ----------------------------------
// sheet:remove
// ----------------------------------
{
displayName: 'Sheet ID',
name: 'id',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: ['sheet'],
operation: ['remove'],
},
},
description: 'The ID of the sheet to delete',
},
],
};

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