first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { router } from './actions/router';
|
||||
import { versionDescription } from './actions/versionDescription';
|
||||
import { credentialTest, listSearch, loadOptions, resourceMapping } from './methods';
|
||||
|
||||
export class GoogleSheetsV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
loadOptions,
|
||||
credentialTest,
|
||||
listSearch,
|
||||
resourceMapping,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { type IExecuteFunctions, type IDataObject, type INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import * as sheet from './sheet/Sheet.resource';
|
||||
import * as spreadsheet from './spreadsheet/SpreadSheet.resource';
|
||||
import { GoogleSheet } from '../helpers/GoogleSheet';
|
||||
import type { GoogleSheets, ResourceLocator } from '../helpers/GoogleSheets.types';
|
||||
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
let operationResult: INodeExecutionData[] = [];
|
||||
|
||||
try {
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const googleSheets = {
|
||||
resource,
|
||||
operation,
|
||||
} as GoogleSheets;
|
||||
|
||||
let results: INodeExecutionData[] | undefined;
|
||||
if (googleSheets.resource === 'sheet') {
|
||||
const { mode, value } = this.getNodeParameter('documentId', 0) as IDataObject;
|
||||
const spreadsheetId = getSpreadsheetId(
|
||||
this.getNode(),
|
||||
mode as ResourceLocator,
|
||||
value as string,
|
||||
);
|
||||
|
||||
const googleSheet = new GoogleSheet(spreadsheetId, this);
|
||||
|
||||
let sheetId = '';
|
||||
let sheetName = '';
|
||||
|
||||
if (operation !== 'create') {
|
||||
const sheetWithinDocument = this.getNodeParameter('sheetName', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as {
|
||||
mode: ResourceLocator;
|
||||
};
|
||||
|
||||
const result = await googleSheet.spreadsheetGetSheet(
|
||||
this.getNode(),
|
||||
sheetMode,
|
||||
sheetWithinDocument,
|
||||
);
|
||||
sheetId = result.sheetId.toString();
|
||||
sheetName = result.title;
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
sheetName = spreadsheetId;
|
||||
break;
|
||||
case 'delete':
|
||||
sheetName = sheetId;
|
||||
break;
|
||||
case 'remove':
|
||||
sheetName = `${spreadsheetId}||${sheetId}`;
|
||||
break;
|
||||
}
|
||||
|
||||
results = await sheet[googleSheets.operation].execute.call(
|
||||
this,
|
||||
googleSheet,
|
||||
sheetName,
|
||||
sheetId,
|
||||
);
|
||||
} else if (googleSheets.resource === 'spreadsheet') {
|
||||
results = await spreadsheet[googleSheets.operation].execute.call(this);
|
||||
}
|
||||
if (results?.length) {
|
||||
operationResult = operationResult.concat(results);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
operationResult.push({ json: this.getInputData(0)[0].json, error });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return [operationResult];
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as append from './append.operation';
|
||||
import * as appendOrUpdate from './appendOrUpdate.operation';
|
||||
import * as clear from './clear.operation';
|
||||
import * as create from './create.operation';
|
||||
import * as del from './delete.operation';
|
||||
import * as read from './read.operation';
|
||||
import * as remove from './remove.operation';
|
||||
import * as update from './update.operation';
|
||||
import { GOOGLE_DRIVE_FILE_URL_REGEX, GOOGLE_SHEETS_SHEET_URL_REGEX } from '../../../../constants';
|
||||
|
||||
export { append, appendOrUpdate, clear, create, del as delete, read, remove, update };
|
||||
|
||||
export const descriptions: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Append or Update Row',
|
||||
value: 'appendOrUpdate',
|
||||
description: 'Append a new row or update an existing one (upsert)',
|
||||
action: 'Append or update row in sheet',
|
||||
},
|
||||
{
|
||||
name: 'Append Row',
|
||||
value: 'append',
|
||||
description: 'Create a new row in a sheet',
|
||||
action: 'Append row in sheet',
|
||||
},
|
||||
{
|
||||
name: 'Clear',
|
||||
value: 'clear',
|
||||
description: 'Delete all the contents or a part of a sheet',
|
||||
action: 'Clear sheet',
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a new sheet',
|
||||
action: 'Create sheet',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'remove',
|
||||
description: 'Permanently delete a sheet',
|
||||
action: 'Delete sheet',
|
||||
},
|
||||
{
|
||||
name: 'Delete Rows or Columns',
|
||||
value: 'delete',
|
||||
description: 'Delete columns or rows from a sheet',
|
||||
action: 'Delete rows or columns from sheet',
|
||||
},
|
||||
{
|
||||
name: 'Get Row(s)',
|
||||
value: 'read',
|
||||
description: 'Retrieve one or more rows from a sheet',
|
||||
action: 'Get row(s) in sheet',
|
||||
},
|
||||
{
|
||||
name: 'Update Row',
|
||||
value: 'update',
|
||||
description: 'Update an existing row in a sheet',
|
||||
action: 'Update row in sheet',
|
||||
},
|
||||
],
|
||||
default: 'read',
|
||||
},
|
||||
{
|
||||
displayName: 'Document',
|
||||
name: 'documentId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
builderHint: { message: "Default to mode: 'list' which is easier for users to set up" },
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'spreadSheetsSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
},
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
errorMessage: 'Not a valid Google Drive File URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9\\-_]{2,}',
|
||||
errorMessage: 'Not a valid Google Drive File ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
url: '=https://docs.google.com/spreadsheets/d/{{$value}}/edit',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Sheet',
|
||||
name: 'sheetName',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
// default: '', //empty string set to progresivly reveal fields
|
||||
required: true,
|
||||
builderHint: { message: "Default to mode: 'list' which is easier for users to set up" },
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['documentId.value'],
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'sheetsSearch',
|
||||
searchable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: GOOGLE_SHEETS_SHEET_URL_REGEX,
|
||||
},
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: GOOGLE_SHEETS_SHEET_URL_REGEX,
|
||||
errorMessage: 'Not a valid Sheet URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '((gid=)?[0-9]{1,})',
|
||||
errorMessage: 'Not a valid Sheet ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
placeholder: 'Sheet1',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append', 'appendOrUpdate', 'clear', 'delete', 'read', 'remove', 'update'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...append.description,
|
||||
...clear.description,
|
||||
...create.description,
|
||||
...del.description,
|
||||
...read.description,
|
||||
...update.description,
|
||||
...appendOrUpdate.description,
|
||||
];
|
||||
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type IDataObject,
|
||||
type INodeExecutionData,
|
||||
NodeOperationError,
|
||||
type ResourceMapperField,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { cellFormat, handlingExtraData, useAppendOption } from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties, ValueInputOption } from '../../helpers/GoogleSheets.types';
|
||||
import {
|
||||
autoMapInputData,
|
||||
cellFormatDefault,
|
||||
checkForSchemaChanges,
|
||||
mapFields,
|
||||
untilSheetSelected,
|
||||
} from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: 'autoMapInputData',
|
||||
description: 'Use when node input properties match destination column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Below',
|
||||
value: 'defineBelow',
|
||||
description: 'Set the value for each destination column',
|
||||
},
|
||||
{
|
||||
name: 'Nothing',
|
||||
value: 'nothing',
|
||||
description: 'Do not send anything',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'defineBelow',
|
||||
description: 'Whether to insert the input data this node receives in the new row',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
"In this mode, make sure the incoming data is named the same as the columns in your Sheet. (Use an 'Edit Fields' node before this node to change it if required.)",
|
||||
name: 'autoMapNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['append'],
|
||||
dataMode: ['autoMapInputData'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Send',
|
||||
name: 'fieldsUi',
|
||||
placeholder: 'Add Field',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Field to Send',
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Field',
|
||||
name: 'fieldValues',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Field Name or ID',
|
||||
name: 'fieldId',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Field Value',
|
||||
name: 'fieldValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'add',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append'],
|
||||
'@version': [{ _cnd: { gte: 4 } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
cellFormat,
|
||||
{
|
||||
displayName: 'Data Location on Sheet',
|
||||
name: 'locationDefine',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Select Range',
|
||||
default: { values: {} },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Header Row',
|
||||
name: 'headerRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description:
|
||||
'Index of the row which contains the keys. Starts at 1. The incoming node data is matched to the keys for assignment. The matching is case sensitive.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
handlingExtraData,
|
||||
{
|
||||
...handlingExtraData,
|
||||
displayOptions: { show: { '/columns.mappingMode': ['autoMapInputData'] } },
|
||||
},
|
||||
useAppendOption,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
range: string,
|
||||
sheetId: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
let dataMode =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('dataMode', 0) as string)
|
||||
: (this.getNodeParameter('columns.mappingMode', 0) as string);
|
||||
|
||||
if (!items.length || dataMode === 'nothing') return [];
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {});
|
||||
const locationDefine = (options.locationDefine as IDataObject)?.values as IDataObject;
|
||||
|
||||
let keyRowIndex = 1;
|
||||
if (locationDefine?.headerRow) {
|
||||
keyRowIndex = locationDefine.headerRow as number;
|
||||
}
|
||||
|
||||
const sheetData = await sheet.getData(range, 'FORMATTED_VALUE');
|
||||
|
||||
if (!sheetData?.length) {
|
||||
dataMode = 'autoMapInputData';
|
||||
}
|
||||
|
||||
if (nodeVersion >= 4.4 && dataMode !== 'autoMapInputData') {
|
||||
//not possible to refresh columns when mode is autoMapInputData
|
||||
if (sheetData?.[keyRowIndex - 1] === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Could not retrieve the column names from row ${keyRowIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
const schema = this.getNodeParameter('columns.schema', 0) as ResourceMapperField[];
|
||||
checkForSchemaChanges(this.getNode(), sheetData[keyRowIndex - 1], schema);
|
||||
}
|
||||
|
||||
let inputData: IDataObject[] = [];
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
inputData = await autoMapInputData.call(this, range, sheet, items, options);
|
||||
} else {
|
||||
inputData = mapFields.call(this, items.length);
|
||||
}
|
||||
|
||||
if (inputData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const valueInputMode = (options.cellFormat as ValueInputOption) || cellFormatDefault(nodeVersion);
|
||||
const useAppend = options.useAppend as boolean;
|
||||
|
||||
if (options.useAppend) {
|
||||
await sheet.appendSheetData({
|
||||
inputData,
|
||||
range,
|
||||
keyRowIndex,
|
||||
valueInputMode,
|
||||
useAppend,
|
||||
});
|
||||
} else {
|
||||
//if no trailing empty row exists in the sheet update operation will fail
|
||||
await sheet.appendEmptyRowsOrColumns(sheetId, 1, 0);
|
||||
|
||||
// if sheetData is undefined it means that the sheet was empty
|
||||
// we did add row with column names in the first row (autoMapInputData)
|
||||
// to account for that length has to be 1 and we append data in the next row
|
||||
const lastRow = (sheetData ?? [{}]).length + 1;
|
||||
|
||||
await sheet.appendSheetData({
|
||||
inputData,
|
||||
range,
|
||||
keyRowIndex,
|
||||
valueInputMode,
|
||||
lastRow,
|
||||
});
|
||||
}
|
||||
|
||||
if (nodeVersion < 4 || dataMode === 'autoMapInputData') {
|
||||
return items.map((item, index) => {
|
||||
item.pairedItem = { item: index };
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
for (const [index, entry] of inputData.entries()) {
|
||||
returnData.push({
|
||||
json: entry,
|
||||
pairedItem: { item: index },
|
||||
});
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
ResourceMapperField,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
cellFormat,
|
||||
handlingExtraData,
|
||||
locationDefine,
|
||||
useAppendOption,
|
||||
} from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import {
|
||||
ROW_NUMBER,
|
||||
type ISheetUpdateData,
|
||||
type SheetProperties,
|
||||
type ValueInputOption,
|
||||
type ValueRenderOption,
|
||||
} from '../../helpers/GoogleSheets.types';
|
||||
import {
|
||||
cellFormatDefault,
|
||||
checkForSchemaChanges,
|
||||
untilSheetSelected,
|
||||
} from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: 'autoMapInputData',
|
||||
description: 'Use when node input properties match destination column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Below',
|
||||
value: 'defineBelow',
|
||||
description: 'Set the value for each destination column',
|
||||
},
|
||||
{
|
||||
name: 'Nothing',
|
||||
value: 'nothing',
|
||||
description: 'Do not send anything',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'defineBelow',
|
||||
description: 'Whether to insert the input data this node receives in the new row',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column to match on',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
|
||||
},
|
||||
default: '',
|
||||
hint: "Used to find the correct row to update. Doesn't get changed.",
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value of Column to Match On',
|
||||
name: 'valueToMatchOn',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'fieldsUi',
|
||||
placeholder: 'Add Field',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Field',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value', 'columnToMatchOn'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndAddColumn',
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Column Name',
|
||||
name: 'columnName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
column: ['newColumn'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'fieldValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'upsert',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
allowEmptyValues: true,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
'@version': [{ _cnd: { gte: 4.7 } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'upsert',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
'@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
cellFormat,
|
||||
locationDefine,
|
||||
handlingExtraData,
|
||||
{
|
||||
...handlingExtraData,
|
||||
displayOptions: { show: { '/columns.mappingMode': ['autoMapInputData'] } },
|
||||
},
|
||||
useAppendOption,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
sheetId: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
|
||||
const range = `${sheetName}!A:Z`;
|
||||
|
||||
const valueInputMode = this.getNodeParameter(
|
||||
'options.cellFormat',
|
||||
0,
|
||||
cellFormatDefault(nodeVersion),
|
||||
) as ValueInputOption;
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {});
|
||||
|
||||
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
|
||||
|
||||
const locationDefineOption = (options.locationDefine as IDataObject)?.values as IDataObject;
|
||||
|
||||
let keyRowIndex = 0;
|
||||
let dataStartRowIndex = 1;
|
||||
|
||||
if (locationDefineOption) {
|
||||
if (locationDefineOption.headerRow) {
|
||||
keyRowIndex = parseInt(locationDefineOption.headerRow as string, 10) - 1;
|
||||
}
|
||||
if (locationDefineOption.firstDataRow) {
|
||||
dataStartRowIndex = parseInt(locationDefineOption.firstDataRow as string, 10) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
let dataMode =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('dataMode', 0) as string)
|
||||
: (this.getNodeParameter('columns.mappingMode', 0) as string);
|
||||
|
||||
let columnNames: string[] = [];
|
||||
|
||||
const sheetData = (await sheet.getData(sheetName, 'FORMATTED_VALUE')) ?? [];
|
||||
|
||||
if (!sheetData[keyRowIndex] && dataMode !== 'autoMapInputData') {
|
||||
if (!sheetData.length) {
|
||||
dataMode = 'autoMapInputData';
|
||||
} else {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Could not retrieve the column names from row ${keyRowIndex + 1}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
columnNames = sheetData[keyRowIndex] ?? [];
|
||||
|
||||
if (nodeVersion >= 4.4) {
|
||||
const schema = this.getNodeParameter('columns.schema', 0) as ResourceMapperField[];
|
||||
checkForSchemaChanges(this.getNode(), columnNames, schema);
|
||||
}
|
||||
|
||||
const newColumns = new Set<string>();
|
||||
|
||||
const columnsToMatchOn: string[] =
|
||||
nodeVersion < 4
|
||||
? [this.getNodeParameter('columnToMatchOn', 0) as string]
|
||||
: (this.getNodeParameter('columns.matchingColumns', 0) as string[]);
|
||||
|
||||
// TODO: Add support for multiple columns to match on in the next overhaul
|
||||
const keyIndex = columnNames.indexOf(columnsToMatchOn[0]);
|
||||
|
||||
const columnValuesList = await sheet.getColumnValues({
|
||||
range,
|
||||
keyIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
sheetData,
|
||||
});
|
||||
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
const appendData: IDataObject[] = [];
|
||||
|
||||
const errorOnUnexpectedColumn = (key: string, i: number) => {
|
||||
if (!columnNames.includes(key)) {
|
||||
throw new NodeOperationError(this.getNode(), 'Unexpected fields in node input', {
|
||||
itemIndex: i,
|
||||
description: `The input field '${key}' doesn't match any column in the Sheet. You can ignore this by changing the 'Handling extra data' field, which you can find under 'Options'.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addNewColumn = (key: string) => {
|
||||
if (!columnNames.includes(key) && key !== ROW_NUMBER) {
|
||||
newColumns.add(key);
|
||||
}
|
||||
};
|
||||
|
||||
const mappedValues: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (dataMode === 'nothing') continue;
|
||||
|
||||
const inputData: IDataObject[] = [];
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
const handlingExtraDataOption = (options.handlingExtraData as string) || 'insertInNewColumn';
|
||||
if (handlingExtraDataOption === 'ignoreIt') {
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
if (handlingExtraDataOption === 'error') {
|
||||
Object.keys(items[i].json).forEach((key) => errorOnUnexpectedColumn(key, i));
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
if (handlingExtraDataOption === 'insertInNewColumn') {
|
||||
Object.keys(items[i].json).forEach(addNewColumn);
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
} else {
|
||||
const valueToMatchOn =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('valueToMatchOn', i, '') as string)
|
||||
: (this.getNodeParameter(`columns.value[${columnsToMatchOn[0]}]`, i, '') as string);
|
||||
|
||||
if (valueToMatchOn === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"The 'Column to Match On' parameter is required",
|
||||
{
|
||||
itemIndex: i,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeVersion < 4) {
|
||||
const valuesToSend = this.getNodeParameter('fieldsUi.values', i, []) as IDataObject[];
|
||||
if (!valuesToSend?.length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
|
||||
const fields = valuesToSend.reduce((acc, entry) => {
|
||||
if (entry.column === 'newColumn') {
|
||||
const columnName = entry.columnName as string;
|
||||
if (!columnNames.includes(columnName)) {
|
||||
newColumns.add(columnName);
|
||||
}
|
||||
acc[columnName] = entry.fieldValue as string;
|
||||
} else {
|
||||
acc[entry.column as string] = entry.fieldValue as string;
|
||||
}
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
fields[columnsToMatchOn[0]] = valueToMatchOn;
|
||||
inputData.push(fields);
|
||||
} else {
|
||||
const mappingValues = this.getNodeParameter('columns.value', i) as IDataObject;
|
||||
if (Object.keys(mappingValues).length === 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
// Setting empty values to empty string so that they are not ignored by the API
|
||||
Object.keys(mappingValues).forEach((key) => {
|
||||
if (mappingValues[key] === undefined || mappingValues[key] === null) {
|
||||
mappingValues[key] = '';
|
||||
}
|
||||
});
|
||||
inputData.push(mappingValues);
|
||||
mappedValues.push(mappingValues);
|
||||
}
|
||||
}
|
||||
|
||||
if (newColumns.size) {
|
||||
const newColumnNames = columnNames.concat([...newColumns]);
|
||||
await sheet.updateRows(
|
||||
sheetName,
|
||||
[newColumnNames],
|
||||
(options.cellFormat as ValueInputOption) || cellFormatDefault(nodeVersion),
|
||||
keyRowIndex + 1,
|
||||
);
|
||||
columnNames = newColumnNames;
|
||||
sheetData[keyRowIndex] = newColumnNames;
|
||||
newColumns.clear();
|
||||
}
|
||||
|
||||
const indexKey = columnsToMatchOn[0];
|
||||
|
||||
const preparedData = await sheet.prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey,
|
||||
range,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
upsert: true,
|
||||
columnNamesList: [columnNames.concat([...newColumns])],
|
||||
columnValuesList,
|
||||
});
|
||||
|
||||
updateData.push(...preparedData.updateData);
|
||||
appendData.push(...preparedData.appendData);
|
||||
}
|
||||
|
||||
const columnNamesList = [columnNames.concat([...newColumns])];
|
||||
|
||||
if (updateData.length) {
|
||||
await sheet.batchUpdate(updateData, valueInputMode);
|
||||
}
|
||||
if (appendData.length) {
|
||||
const lastRow = sheetData.length + 1;
|
||||
const useAppend = options.useAppend as boolean;
|
||||
|
||||
if (options.useAppend) {
|
||||
await sheet.appendSheetData({
|
||||
inputData: appendData,
|
||||
range,
|
||||
keyRowIndex: keyRowIndex + 1,
|
||||
valueInputMode,
|
||||
columnNamesList,
|
||||
lastRow,
|
||||
useAppend,
|
||||
});
|
||||
} else {
|
||||
await sheet.appendEmptyRowsOrColumns(sheetId, 1, 0);
|
||||
await sheet.appendSheetData({
|
||||
inputData: appendData,
|
||||
range,
|
||||
keyRowIndex: keyRowIndex + 1,
|
||||
valueInputMode,
|
||||
columnNamesList,
|
||||
lastRow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeVersion < 4 || dataMode === 'autoMapInputData') {
|
||||
return items.map((item, index) => {
|
||||
item.pairedItem = { item: index };
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
for (const [index, entry] of mappedValues.entries()) {
|
||||
returnData.push({
|
||||
json: entry,
|
||||
pairedItem: { item: index },
|
||||
});
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import {
|
||||
getColumnName,
|
||||
getColumnNumber,
|
||||
untilSheetSelected,
|
||||
} from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Clear',
|
||||
name: 'clear',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Whole Sheet',
|
||||
value: 'wholeSheet',
|
||||
},
|
||||
{
|
||||
name: 'Specific Rows',
|
||||
value: 'specificRows',
|
||||
},
|
||||
{
|
||||
name: 'Specific Columns',
|
||||
value: 'specificColumns',
|
||||
},
|
||||
{
|
||||
name: 'Specific Range',
|
||||
value: 'specificRange',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'wholeSheet',
|
||||
description: 'What to clear',
|
||||
},
|
||||
{
|
||||
displayName: 'Keep First Row',
|
||||
name: 'keepFirstRow',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['wholeSheet'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Start Row Number',
|
||||
name: 'startIndex',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: 'The row number to delete from, The first row is 1',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificRows'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Rows to Delete',
|
||||
name: 'rowsToDelete',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificRows'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Start Column',
|
||||
name: 'startIndex',
|
||||
type: 'string',
|
||||
default: 'A',
|
||||
description: 'The column to delete',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificColumns'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Could this be better as "end column"?
|
||||
displayName: 'Number of Columns to Delete',
|
||||
name: 'columnsToDelete',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificColumns'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Range',
|
||||
name: 'range',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificRange'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'A:F',
|
||||
required: true,
|
||||
description:
|
||||
'The table range to read from or to append data to. See the Google <a href="https://developers.google.com/sheets/api/guides/values#writing">documentation</a> for the details. If it contains multiple sheets it can also be added like this: "MySheet!A:F"',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const clearType = this.getNodeParameter('clear', i) as string;
|
||||
const keepFirstRow = this.getNodeParameter('keepFirstRow', i, false) as boolean;
|
||||
let range = '';
|
||||
|
||||
if (clearType === 'specificRows') {
|
||||
const startIndex = this.getNodeParameter('startIndex', i) as number;
|
||||
const rowsToDelete = this.getNodeParameter('rowsToDelete', i) as number;
|
||||
const endIndex = rowsToDelete === 1 ? startIndex : startIndex + rowsToDelete - 1;
|
||||
|
||||
range = `${sheetName}!${startIndex}:${endIndex}`;
|
||||
}
|
||||
|
||||
if (clearType === 'specificColumns') {
|
||||
const startIndex = this.getNodeParameter('startIndex', i) as string;
|
||||
const columnsToDelete = this.getNodeParameter('columnsToDelete', i) as number;
|
||||
const columnNumber = getColumnNumber(startIndex);
|
||||
const endIndex = columnsToDelete === 1 ? columnNumber : columnNumber + columnsToDelete - 1;
|
||||
|
||||
range = `${sheetName}!${startIndex}:${getColumnName(endIndex)}`;
|
||||
}
|
||||
|
||||
if (clearType === 'specificRange') {
|
||||
const rangeField = this.getNodeParameter('range', i) as string;
|
||||
const region = rangeField.includes('!') ? rangeField.split('!')[1] || '' : rangeField;
|
||||
|
||||
range = `${sheetName}!${region}`;
|
||||
}
|
||||
|
||||
if (clearType === 'wholeSheet') {
|
||||
range = sheetName;
|
||||
}
|
||||
|
||||
if (keepFirstRow) {
|
||||
const firstRow = await sheet.getData(`${range}!1:1`, 'FORMATTED_VALUE');
|
||||
await sheet.clearData(range);
|
||||
await sheet.updateRows(range, firstRow as string[][], 'RAW', 1);
|
||||
} else {
|
||||
await sheet.clearData(range);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const dataLocationOnSheet: INodeProperties = {
|
||||
displayName: 'Data Location on Sheet',
|
||||
name: 'dataLocationOnSheet',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Select Range',
|
||||
default: { values: { rangeDefinition: 'detectAutomatically' } },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Range Definition',
|
||||
name: 'rangeDefinition',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Detect Automatically',
|
||||
value: 'detectAutomatically',
|
||||
description: 'Automatically detect the data range',
|
||||
},
|
||||
{
|
||||
name: 'Specify Range (A1 Notation)',
|
||||
value: 'specifyRangeA1',
|
||||
description: 'Manually specify the data range',
|
||||
},
|
||||
{
|
||||
name: 'Specify Range (Rows)',
|
||||
value: 'specifyRange',
|
||||
description: 'Manually specify the data range',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Read Rows Until',
|
||||
name: 'readRowsUntil',
|
||||
type: 'options',
|
||||
default: 'lastRowInSheet',
|
||||
options: [
|
||||
{
|
||||
name: 'First Empty Row',
|
||||
value: 'firstEmptyRow',
|
||||
},
|
||||
{
|
||||
name: 'Last Row In Sheet',
|
||||
value: 'lastRowInSheet',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['detectAutomatically'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Header Row',
|
||||
name: 'headerRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: "Index is relative to the set 'Range', first row index is 1",
|
||||
hint: 'Index of the row which contains the column names',
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['specifyRange'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'First Data Row',
|
||||
name: 'firstDataRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 2,
|
||||
description: "Index is relative to the set 'Range', first row index is 1",
|
||||
hint: 'Index of first row which contains the actual data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['specifyRange'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Range',
|
||||
name: 'range',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'A:Z',
|
||||
description:
|
||||
'The table range to read from or to append data to. See the Google <a href="https://developers.google.com/sheets/api/guides/values#writing">documentation</a> for the details.',
|
||||
hint: 'You can specify both the rows and the columns, e.g. C4:E7',
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['specifyRangeA1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const locationDefine: INodeProperties = {
|
||||
displayName: 'Data Location on Sheet',
|
||||
name: 'locationDefine',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Select Range',
|
||||
default: { values: {} },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Header Row',
|
||||
name: 'headerRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: "Index is relative to the set 'Range', first row index is 1",
|
||||
hint: 'Index of the row which contains the column names',
|
||||
},
|
||||
{
|
||||
displayName: 'First Data Row',
|
||||
name: 'firstDataRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 2,
|
||||
description: "Index is relative to the set 'Range', first row index is 1",
|
||||
hint: 'Index of first row which contains the actual data',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const outputFormatting: INodeProperties = {
|
||||
displayName: 'Output Formatting',
|
||||
name: 'outputFormatting',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Add Formatting',
|
||||
default: { values: { general: 'UNFORMATTED_VALUE', date: 'FORMATTED_STRING' } },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'General Formatting',
|
||||
name: 'general',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Values (unformatted)',
|
||||
value: 'UNFORMATTED_VALUE',
|
||||
description:
|
||||
'Numbers stay as numbers, but any currency signs or special formatting is lost',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Values (formatted)',
|
||||
value: 'FORMATTED_VALUE',
|
||||
description:
|
||||
'Numbers are turned to text, and displayed as in Google Sheets (e.g. with commas or currency signs)',
|
||||
},
|
||||
{
|
||||
name: 'Formulas',
|
||||
value: 'FORMULA',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
description: 'Determines how values should be rendered in the output',
|
||||
},
|
||||
{
|
||||
displayName: 'Date Formatting',
|
||||
name: 'date',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [
|
||||
{
|
||||
name: 'Formatted Text',
|
||||
value: 'FORMATTED_STRING',
|
||||
description: "As displayed in Google Sheets, e.g. '01/01/2022'",
|
||||
},
|
||||
{
|
||||
name: 'Serial Number',
|
||||
value: 'SERIAL_NUMBER',
|
||||
description: 'A number representing the number of days since Dec 30, 1899',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const cellFormat: INodeProperties = {
|
||||
displayName: 'Cell Format',
|
||||
name: 'cellFormat',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Let Google Sheets format',
|
||||
value: 'USER_ENTERED',
|
||||
description: 'Cells are styled as if you typed the values into Google Sheets directly',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Let n8n format',
|
||||
value: 'RAW',
|
||||
description: 'Cells have the same types as the input data',
|
||||
},
|
||||
],
|
||||
default: 'USER_ENTERED',
|
||||
description: 'Determines how data should be interpreted',
|
||||
};
|
||||
|
||||
export const handlingExtraData: INodeProperties = {
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
displayName: 'Handling extra fields in input',
|
||||
name: 'handlingExtraData',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Insert in New Column(s)',
|
||||
value: 'insertInNewColumn',
|
||||
description: 'Create a new column for extra data',
|
||||
},
|
||||
{
|
||||
name: 'Ignore Them',
|
||||
value: 'ignoreIt',
|
||||
description: 'Ignore extra data',
|
||||
},
|
||||
{
|
||||
name: 'Error',
|
||||
value: 'error',
|
||||
description: 'Throw an error',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/dataMode': ['autoMapInputData'],
|
||||
},
|
||||
},
|
||||
default: 'insertInNewColumn',
|
||||
description: "What do to with fields that don't match any columns in the Google Sheet",
|
||||
};
|
||||
|
||||
export const useAppendOption: INodeProperties = {
|
||||
displayName: 'Minimise API Calls',
|
||||
name: 'useAppend',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
hint: 'Use if your sheet has no gaps between rows or columns',
|
||||
description:
|
||||
'Whether to use append instead of update(default), this is more efficient but in some cases data might be misaligned',
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { wrapData } from '../../../../../../utils/utilities';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { getExistingSheetNames, hexToRgb } from '../../helpers/GoogleSheets.utils';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'n8n-sheet',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
description: 'The name of the sheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Hidden',
|
||||
name: 'hidden',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: "Whether the sheet is hidden in the UI, false if it's visible",
|
||||
},
|
||||
{
|
||||
displayName: 'Right To Left',
|
||||
name: 'rightToLeft',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether the sheet is an RTL sheet instead of an LTR sheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Sheet ID',
|
||||
name: 'sheetId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'The ID of the sheet. Must be non-negative. This field cannot be changed once set.',
|
||||
},
|
||||
{
|
||||
displayName: 'Sheet Index',
|
||||
name: 'index',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'The index of the sheet within the spreadsheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Tab Color',
|
||||
name: 'tabColor',
|
||||
type: 'color',
|
||||
default: '0aa55c',
|
||||
description: 'The color of the tab in the UI',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
let responseData;
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const items = this.getInputData();
|
||||
|
||||
const existingSheetNames = await getExistingSheetNames(sheet);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const sheetTitle = this.getNodeParameter('title', i, {}) as string;
|
||||
|
||||
if (existingSheetNames.includes(sheetTitle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const properties = { ...options };
|
||||
properties.title = sheetTitle;
|
||||
|
||||
if (options.tabColor) {
|
||||
const { red, green, blue } = hexToRgb(options.tabColor as string)!;
|
||||
properties.tabColor = { red: red / 255, green: green / 255, blue: blue / 255 };
|
||||
}
|
||||
|
||||
const requests = [
|
||||
{
|
||||
addSheet: {
|
||||
properties,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
responseData = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${sheetName}:batchUpdate`,
|
||||
{ requests },
|
||||
);
|
||||
|
||||
// simplify response
|
||||
Object.assign(responseData, responseData.replies[0].addSheet.properties);
|
||||
delete responseData.replies;
|
||||
|
||||
existingSheetNames.push(sheetTitle);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { generatePairedItemData, wrapData } from '../../../../../../utils/utilities';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { getColumnNumber, untilSheetSelected } from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'To Delete',
|
||||
name: 'toDelete',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Rows',
|
||||
value: 'rows',
|
||||
description: 'Rows to delete',
|
||||
},
|
||||
{
|
||||
name: 'Columns',
|
||||
value: 'columns',
|
||||
description: 'Columns to delete',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'rows',
|
||||
description: 'What to delete',
|
||||
},
|
||||
{
|
||||
displayName: 'Start Row Number',
|
||||
name: 'startIndex',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 2,
|
||||
description: 'The row number to delete from, The first row is 2',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
toDelete: ['rows'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Rows to Delete',
|
||||
name: 'numberToDelete',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
toDelete: ['rows'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Start Column',
|
||||
name: 'startIndex',
|
||||
type: 'string',
|
||||
default: 'A',
|
||||
description: 'The column to delete',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
toDelete: ['columns'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Columns to Delete',
|
||||
name: 'numberToDelete',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
toDelete: ['columns'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const requests: IDataObject[] = [];
|
||||
let startIndex, endIndex, numberToDelete;
|
||||
const deleteType = this.getNodeParameter('toDelete', i) as string;
|
||||
|
||||
if (deleteType === 'rows') {
|
||||
startIndex = this.getNodeParameter('startIndex', i) as number;
|
||||
// We start from 1 now...
|
||||
startIndex--;
|
||||
numberToDelete = this.getNodeParameter('numberToDelete', i) as number;
|
||||
if (numberToDelete === 1) {
|
||||
endIndex = startIndex + 1;
|
||||
} else {
|
||||
endIndex = startIndex + numberToDelete;
|
||||
}
|
||||
requests.push({
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId: sheetName,
|
||||
dimension: 'ROWS',
|
||||
startIndex,
|
||||
endIndex,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (deleteType === 'columns') {
|
||||
startIndex = this.getNodeParameter('startIndex', i) as string;
|
||||
numberToDelete = this.getNodeParameter('numberToDelete', i) as number;
|
||||
startIndex = getColumnNumber(startIndex) - 1;
|
||||
if (numberToDelete === 1) {
|
||||
endIndex = startIndex + 1;
|
||||
} else {
|
||||
endIndex = startIndex + numberToDelete;
|
||||
}
|
||||
requests.push({
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId: sheetName,
|
||||
dimension: 'COLUMNS',
|
||||
startIndex,
|
||||
endIndex,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
await sheet.spreadsheetBatchUpdate(requests);
|
||||
}
|
||||
|
||||
const itemData = generatePairedItemData(this.getInputData().length);
|
||||
const returnData = this.helpers.constructExecutionMetaData(wrapData({ success: true }), {
|
||||
itemData,
|
||||
});
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { dataLocationOnSheet, outputFormatting } from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { untilSheetSelected } from '../../helpers/GoogleSheets.utils';
|
||||
import { readSheet } from '../utils/readOperation';
|
||||
|
||||
const combineFiltersOptions: INodeProperties = {
|
||||
displayName: 'Combine Filters',
|
||||
name: 'combineFilters',
|
||||
type: 'options',
|
||||
description:
|
||||
'How to combine the conditions defined in "Filters": AND requires all conditions to be true, OR requires at least one condition to be true',
|
||||
options: [
|
||||
{
|
||||
name: 'AND',
|
||||
value: 'AND',
|
||||
description: 'Only rows that meet all the conditions are selected',
|
||||
},
|
||||
{
|
||||
name: 'OR',
|
||||
value: 'OR',
|
||||
description: 'Rows that meet at least one condition are selected',
|
||||
},
|
||||
],
|
||||
default: 'AND',
|
||||
};
|
||||
|
||||
export const readFilter: INodeProperties = {
|
||||
displayName: 'Filters',
|
||||
name: 'filtersUI',
|
||||
placeholder: 'Add Filter',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Filter',
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Filter',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'lookupColumn',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowWithGeneratedColumnNames',
|
||||
},
|
||||
default: '',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'lookupValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
hint: 'The column must have this value to be matched',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
...readFilter,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...combineFiltersOptions,
|
||||
default: 'OR',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 4.3 } }],
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...combineFiltersOptions,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 4.3 } }],
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
dataLocationOnSheet,
|
||||
outputFormatting,
|
||||
{
|
||||
displayName: 'Return only First Matching Row',
|
||||
name: 'returnFirstMatch',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to select the first row of the sheet or the first matching row (if filters are set)',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 4.5 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'When Filter Has Multiple Matches',
|
||||
name: 'returnAllMatches',
|
||||
type: 'options',
|
||||
default: 'returnFirstMatch',
|
||||
options: [
|
||||
{
|
||||
name: 'Return First Match',
|
||||
value: 'returnFirstMatch',
|
||||
description: 'Return only the first match',
|
||||
},
|
||||
{
|
||||
name: 'Return All Matches',
|
||||
value: 'returnAllMatches',
|
||||
description: 'Return all values that match',
|
||||
},
|
||||
],
|
||||
description:
|
||||
'By default only the first result gets returned, Set to "Return All Matches" to get multiple matches',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 4.5 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
let length = 1;
|
||||
|
||||
if (nodeVersion > 4.1) {
|
||||
length = items.length;
|
||||
}
|
||||
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let itemIndex = 0; itemIndex < length; itemIndex++) {
|
||||
returnData = await readSheet.call(
|
||||
this,
|
||||
sheet,
|
||||
sheetName,
|
||||
itemIndex,
|
||||
returnData,
|
||||
nodeVersion,
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { wrapData } from '../../../../../../utils/utilities';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
_sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const items = this.getInputData();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const [spreadsheetId, sheetWithinDocument] = sheetName.split('||');
|
||||
const requests = [
|
||||
{
|
||||
deleteSheet: {
|
||||
sheetId: sheetWithinDocument,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const responseData = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
|
||||
{ requests },
|
||||
);
|
||||
delete responseData.replies;
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError, UserError } from 'n8n-workflow';
|
||||
|
||||
import { cellFormat, handlingExtraData, locationDefine } from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import {
|
||||
ROW_NUMBER,
|
||||
type ISheetUpdateData,
|
||||
type SheetProperties,
|
||||
type ValueInputOption,
|
||||
type ValueRenderOption,
|
||||
} from '../../helpers/GoogleSheets.types';
|
||||
import { cellFormatDefault, untilSheetSelected } from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: 'autoMapInputData',
|
||||
description: 'Use when node input properties match destination column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Below',
|
||||
value: 'defineBelow',
|
||||
description: 'Set the value for each destination column',
|
||||
},
|
||||
{
|
||||
name: 'Nothing',
|
||||
value: 'nothing',
|
||||
description: 'Do not send anything',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'defineBelow',
|
||||
description: 'Whether to insert the input data this node receives in the new row',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column to match on',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
|
||||
},
|
||||
default: '',
|
||||
hint: "Used to find the correct row to update. Doesn't get changed.",
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value of Column to Match On',
|
||||
name: 'valueToMatchOn',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'fieldsUi',
|
||||
placeholder: 'Add Field',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Field',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value', 'columnToMatchOn'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndAddColumn',
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Column Name',
|
||||
name: 'columnName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
column: ['newColumn'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'fieldValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'update',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
allowEmptyValues: true,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
'@version': [{ _cnd: { gte: 4.7 } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'update',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
'@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
cellFormat,
|
||||
locationDefine,
|
||||
handlingExtraData,
|
||||
{
|
||||
...handlingExtraData,
|
||||
displayOptions: { show: { '/columns.mappingMode': ['autoMapInputData'] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
|
||||
const range = `${sheetName}!A:Z`;
|
||||
|
||||
const valueInputMode = this.getNodeParameter(
|
||||
'options.cellFormat',
|
||||
0,
|
||||
cellFormatDefault(nodeVersion),
|
||||
) as ValueInputOption;
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {});
|
||||
|
||||
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
|
||||
|
||||
const locationDefineOptions = (options.locationDefine as IDataObject)?.values as IDataObject;
|
||||
|
||||
let keyRowIndex = 0;
|
||||
let dataStartRowIndex = 1;
|
||||
|
||||
if (locationDefineOptions) {
|
||||
if (locationDefineOptions.headerRow) {
|
||||
keyRowIndex = parseInt(locationDefineOptions.headerRow as string, 10) - 1;
|
||||
}
|
||||
if (locationDefineOptions.firstDataRow) {
|
||||
dataStartRowIndex = parseInt(locationDefineOptions.firstDataRow as string, 10) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
let columnNames: string[] = [];
|
||||
|
||||
const sheetData = await sheet.getData(sheetName, 'FORMATTED_VALUE');
|
||||
|
||||
if (sheetData?.[keyRowIndex] === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Could not retrieve the column names from row ${keyRowIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
columnNames = sheetData[keyRowIndex];
|
||||
|
||||
const newColumns = new Set<string>();
|
||||
|
||||
const columnsToMatchOn: string[] =
|
||||
nodeVersion < 4
|
||||
? [this.getNodeParameter('columnToMatchOn', 0) as string]
|
||||
: (this.getNodeParameter('columns.matchingColumns', 0) as string[]);
|
||||
|
||||
const dataMode =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('dataMode', 0) as string)
|
||||
: (this.getNodeParameter('columns.mappingMode', 0) as string);
|
||||
|
||||
// TODO: Add support for multiple columns to match on in the next overhaul
|
||||
const keyIndex = columnNames.indexOf(columnsToMatchOn[0]);
|
||||
|
||||
//not used when updating row
|
||||
const columnValuesList = await sheet.getColumnValues({
|
||||
range,
|
||||
keyIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
sheetData,
|
||||
});
|
||||
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
|
||||
const mappedValues: IDataObject[] = [];
|
||||
|
||||
const errorOnUnexpectedColumn = (key: string, i: number) => {
|
||||
if (!columnNames.includes(key)) {
|
||||
throw new NodeOperationError(this.getNode(), 'Unexpected fields in node input', {
|
||||
itemIndex: i,
|
||||
description: `The input field '${key}' doesn't match any column in the Sheet. You can ignore this by changing the 'Handling extra data' field, which you can find under 'Options'.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addNewColumn = (key: string) => {
|
||||
if (!columnNames.includes(key) && key !== ROW_NUMBER) {
|
||||
newColumns.add(key);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (dataMode === 'nothing') continue;
|
||||
|
||||
const inputData: IDataObject[] = [];
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
const handlingExtraDataOption = (options.handlingExtraData as string) || 'insertInNewColumn';
|
||||
if (handlingExtraDataOption === 'ignoreIt') {
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
if (handlingExtraDataOption === 'error') {
|
||||
Object.keys(items[i].json).forEach((key) => errorOnUnexpectedColumn(key, i));
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
if (handlingExtraDataOption === 'insertInNewColumn') {
|
||||
Object.keys(items[i].json).forEach(addNewColumn);
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
} else {
|
||||
const valueToMatchOn =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('valueToMatchOn', i, '') as string)
|
||||
: (this.getNodeParameter(`columns.value["${columnsToMatchOn[0]}"]`, i, '') as string);
|
||||
|
||||
if (valueToMatchOn === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"The 'Column to Match On' parameter is required",
|
||||
{
|
||||
itemIndex: i,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeVersion < 4) {
|
||||
const valuesToSend = this.getNodeParameter('fieldsUi.values', i, []) as IDataObject[];
|
||||
if (!valuesToSend?.length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
|
||||
const fields = valuesToSend.reduce((acc, entry) => {
|
||||
if (entry.column === 'newColumn') {
|
||||
const columnName = entry.columnName as string;
|
||||
|
||||
if (!columnNames.includes(columnName)) {
|
||||
newColumns.add(columnName);
|
||||
}
|
||||
|
||||
acc[columnName] = entry.fieldValue as string;
|
||||
} else {
|
||||
acc[entry.column as string] = entry.fieldValue as string;
|
||||
}
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
|
||||
fields[columnsToMatchOn[0]] = valueToMatchOn;
|
||||
|
||||
inputData.push(fields);
|
||||
} else {
|
||||
const mappingValues = this.getNodeParameter('columns.value', i) as IDataObject;
|
||||
if (Object.keys(mappingValues).length === 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
// Setting empty values to empty string so that they are not ignored by the API
|
||||
Object.keys(mappingValues).forEach((key) => {
|
||||
// null and undefined values are mapped to undefined
|
||||
if (key === 'row_number' && mappingValues[key] === undefined && nodeVersion >= 4.6) {
|
||||
throw new UserError('row_number is null or undefined', {
|
||||
description:
|
||||
"Since it's being used to determine the row to update, it cannot be null or undefined",
|
||||
});
|
||||
}
|
||||
|
||||
// null and undefined values are mapped to undefined
|
||||
if (mappingValues[key] === undefined) {
|
||||
this.addExecutionHints({
|
||||
message: 'Warning: The value of column to match is null or undefined',
|
||||
location: 'outputPane',
|
||||
});
|
||||
}
|
||||
|
||||
if (mappingValues[key] === undefined || mappingValues[key] === null) {
|
||||
mappingValues[key] = '';
|
||||
}
|
||||
});
|
||||
inputData.push(mappingValues);
|
||||
mappedValues.push(mappingValues);
|
||||
}
|
||||
}
|
||||
|
||||
if (newColumns.size) {
|
||||
const newColumnNames = columnNames.concat([...newColumns]);
|
||||
await sheet.updateRows(
|
||||
sheetName,
|
||||
[newColumnNames],
|
||||
(options.cellFormat as ValueInputOption) || cellFormatDefault(nodeVersion),
|
||||
keyRowIndex + 1,
|
||||
);
|
||||
columnNames = newColumnNames;
|
||||
newColumns.clear();
|
||||
}
|
||||
|
||||
let preparedData;
|
||||
const columnNamesList = [columnNames.concat([...newColumns])];
|
||||
|
||||
if (columnsToMatchOn[0] === 'row_number') {
|
||||
preparedData = sheet.prepareDataForUpdatingByRowNumber(inputData, range, columnNamesList);
|
||||
} else {
|
||||
const indexKey = columnsToMatchOn[0];
|
||||
|
||||
preparedData = await sheet.prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey,
|
||||
range,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
columnNamesList,
|
||||
columnValuesList,
|
||||
});
|
||||
}
|
||||
|
||||
updateData.push(...preparedData.updateData);
|
||||
}
|
||||
|
||||
if (updateData.length) {
|
||||
await sheet.batchUpdate(updateData, valueInputMode);
|
||||
}
|
||||
|
||||
if (nodeVersion < 4 || dataMode === 'autoMapInputData') {
|
||||
return items.map((item, index) => {
|
||||
item.pairedItem = { item: index };
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
if (!updateData.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
for (const [index, entry] of mappedValues.entries()) {
|
||||
returnData.push({
|
||||
json: entry,
|
||||
pairedItem: { item: index },
|
||||
});
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as deleteSpreadsheet from './delete.operation';
|
||||
|
||||
export { create, deleteSpreadsheet };
|
||||
|
||||
export const descriptions: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a spreadsheet',
|
||||
action: 'Create spreadsheet',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'deleteSpreadsheet',
|
||||
description: 'Delete a spreadsheet',
|
||||
action: 'Delete spreadsheet',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
...create.description,
|
||||
...deleteSpreadsheet.description,
|
||||
];
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { wrapData } from '../../../../../../utils/utilities';
|
||||
import type { SpreadSheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: SpreadSheetProperties = [
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
description: 'The title of the spreadsheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Sheets',
|
||||
name: 'sheetsUi',
|
||||
placeholder: 'Add Sheet',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'sheetValues',
|
||||
displayName: 'Sheet',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Title of the property to create',
|
||||
},
|
||||
{
|
||||
displayName: 'Hidden',
|
||||
name: 'hidden',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether the Sheet should be hidden in the UI',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Locale',
|
||||
name: 'locale',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'en_US',
|
||||
description: `The locale of the spreadsheet in one of the following formats:
|
||||
<ul>
|
||||
<li>en (639-1)</li>
|
||||
<li>fil (639-2 if no 639-1 format exists)</li>
|
||||
<li>en_US (combination of ISO language an country)</li>
|
||||
<ul>`,
|
||||
},
|
||||
{
|
||||
displayName: 'Recalculation Interval',
|
||||
name: 'autoRecalc',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Default',
|
||||
value: '',
|
||||
description: 'Default value',
|
||||
},
|
||||
{
|
||||
name: 'On Change',
|
||||
value: 'ON_CHANGE',
|
||||
description: 'Volatile functions are updated on every change',
|
||||
},
|
||||
{
|
||||
name: 'Minute',
|
||||
value: 'MINUTE',
|
||||
description: 'Volatile functions are updated on every change and every minute',
|
||||
},
|
||||
{
|
||||
name: 'Hour',
|
||||
value: 'HOUR',
|
||||
description: 'Volatile functions are updated on every change and hourly',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
description: 'Cell recalculation interval options',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const title = this.getNodeParameter('title', i) as string;
|
||||
const sheetsUi = this.getNodeParameter('sheetsUi', i, {}) as IDataObject;
|
||||
|
||||
const body = {
|
||||
properties: {
|
||||
title,
|
||||
autoRecalc: undefined as undefined | string,
|
||||
locale: undefined as undefined | string,
|
||||
},
|
||||
sheets: [] as IDataObject[],
|
||||
};
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
if (Object.keys(sheetsUi).length) {
|
||||
const data = [];
|
||||
const sheets = sheetsUi.sheetValues as IDataObject[];
|
||||
for (const properties of sheets) {
|
||||
data.push({ properties });
|
||||
}
|
||||
body.sheets = data;
|
||||
}
|
||||
|
||||
body.properties.autoRecalc = options.autoRecalc ? (options.autoRecalc as string) : undefined;
|
||||
body.properties.locale = options.locale ? (options.locale as string) : undefined;
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/v4/spreadsheets', body);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(response as IDataObject),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { wrapData } from '../../../../../../utils/utilities';
|
||||
import { GOOGLE_DRIVE_FILE_URL_REGEX } from '../../../../constants';
|
||||
import type { SpreadSheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: SpreadSheetProperties = [
|
||||
{
|
||||
displayName: 'Document',
|
||||
name: 'documentId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'spreadSheetsSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
},
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
errorMessage: 'Not a valid Google Drive File URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9\\-_]{2,}',
|
||||
errorMessage: 'Not a valid Google Drive File ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
url: '=https://docs.google.com/spreadsheets/d/{{$value}}/edit',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['deleteSpreadsheet'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const documentId = this.getNodeParameter('documentId', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
await apiRequest.call(
|
||||
this,
|
||||
'DELETE',
|
||||
'',
|
||||
{},
|
||||
{},
|
||||
`https://www.googleapis.com/drive/v3/files/${documentId}`,
|
||||
);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(wrapData({ success: true }), {
|
||||
itemData: { item: i },
|
||||
});
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { type GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type {
|
||||
ILookupValues,
|
||||
RangeDetectionOptions,
|
||||
SheetRangeData,
|
||||
ValueRenderOption,
|
||||
} from '../../helpers/GoogleSheets.types';
|
||||
import { getRangeString, prepareSheetData } from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export async function readSheet(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
itemIndex: number,
|
||||
returnData: INodeExecutionData[],
|
||||
nodeVersion: number,
|
||||
items: INodeExecutionData[],
|
||||
rangeString?: string,
|
||||
additionalOptions?: IDataObject,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const options = this.getNodeParameter('options', itemIndex, {});
|
||||
const outputFormattingOption =
|
||||
((options.outputFormatting as IDataObject)?.values as IDataObject) || {};
|
||||
|
||||
const dataLocationOnSheetOptions =
|
||||
((options.dataLocationOnSheet as IDataObject)?.values as RangeDetectionOptions) ||
|
||||
additionalOptions ||
|
||||
{};
|
||||
|
||||
if (dataLocationOnSheetOptions.rangeDefinition === undefined) {
|
||||
dataLocationOnSheetOptions.rangeDefinition = 'detectAutomatically';
|
||||
}
|
||||
|
||||
const includeHeadersWithEmptyCells =
|
||||
(additionalOptions?.includeHeadersWithEmptyCells as boolean) ?? false;
|
||||
|
||||
const range = rangeString ?? getRangeString(sheetName, dataLocationOnSheetOptions);
|
||||
|
||||
const valueRenderMode = (outputFormattingOption.general ||
|
||||
'UNFORMATTED_VALUE') as ValueRenderOption;
|
||||
const dateTimeRenderOption = (outputFormattingOption.date || 'FORMATTED_STRING') as string;
|
||||
|
||||
const sheetData = (await sheet.getData(
|
||||
range,
|
||||
valueRenderMode,
|
||||
dateTimeRenderOption,
|
||||
)) as SheetRangeData;
|
||||
|
||||
if (sheetData === undefined || sheetData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
headerRow: keyRowIndex,
|
||||
firstDataRow: dataStartRowIndex,
|
||||
} = prepareSheetData(sheetData, dataLocationOnSheetOptions as RangeDetectionOptions);
|
||||
|
||||
let responseData = [];
|
||||
|
||||
const lookupValues = this.getNodeParameter('filtersUI.values', itemIndex, []) as ILookupValues[];
|
||||
|
||||
const inputData = data as string[][];
|
||||
|
||||
if (lookupValues.length) {
|
||||
let returnAllMatches;
|
||||
if (nodeVersion < 4.5) {
|
||||
returnAllMatches = options.returnAllMatches === 'returnAllMatches' ? true : false;
|
||||
} else {
|
||||
returnAllMatches =
|
||||
(additionalOptions?.returnFirstMatch ?? options.returnFirstMatch) ? false : true;
|
||||
}
|
||||
|
||||
if (nodeVersion <= 4.1) {
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const itemLookupValues = this.getNodeParameter(
|
||||
'filtersUI.values',
|
||||
i,
|
||||
[],
|
||||
) as ILookupValues[];
|
||||
if (itemLookupValues.length) {
|
||||
lookupValues.push(...itemLookupValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const combineFilters = this.getNodeParameter('combineFilters', itemIndex, 'OR') as 'AND' | 'OR';
|
||||
|
||||
responseData = await sheet.lookupValues({
|
||||
inputData,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
lookupValues,
|
||||
returnAllMatches,
|
||||
nodeVersion,
|
||||
combineFilters,
|
||||
});
|
||||
} else {
|
||||
responseData = sheet.structureArrayDataByColumn(
|
||||
inputData,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
includeHeadersWithEmptyCells,
|
||||
);
|
||||
}
|
||||
|
||||
returnData.push(
|
||||
...responseData.map((item) => {
|
||||
return {
|
||||
json: item,
|
||||
pairedItem: { item: itemIndex },
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import * as sheet from './sheet/Sheet.resource';
|
||||
import * as spreadsheet from './spreadsheet/SpreadSheet.resource';
|
||||
|
||||
export const authentication: INodeProperties = {
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Service Account',
|
||||
value: 'serviceAccount',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'OAuth2 (recommended)',
|
||||
value: 'oAuth2',
|
||||
},
|
||||
],
|
||||
default: 'oAuth2',
|
||||
};
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Google Sheets',
|
||||
name: 'googleSheets',
|
||||
icon: 'file:googleSheets.svg',
|
||||
group: ['input', 'output'],
|
||||
version: [3, 4, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7],
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Read, update and write data to Google Sheets',
|
||||
defaults: {
|
||||
name: 'Google Sheets',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
usableAsTool: true,
|
||||
hints: [
|
||||
{
|
||||
message:
|
||||
"Use the 'Minimise API Calls' option for greater efficiency if your sheet is uniformly formatted without gaps between columns or rows",
|
||||
displayCondition:
|
||||
'={{$parameter["operation"] === "append" && !$parameter["options"]["useAppend"]}}',
|
||||
whenToDisplay: 'beforeExecution',
|
||||
location: 'outputPane',
|
||||
},
|
||||
{
|
||||
message: 'No columns found in Google Sheet. All rows will be appended',
|
||||
displayCondition:
|
||||
'={{ ["appendOrUpdate", "append"].includes($parameter["operation"]) && $parameter?.columns?.mappingMode === "defineBelow" && !$parameter?.columns?.schema?.length }}',
|
||||
whenToDisplay: 'beforeExecution',
|
||||
location: 'outputPane',
|
||||
},
|
||||
{
|
||||
type: 'info',
|
||||
message:
|
||||
'Note on using an expression for Sheet: It will be evaluated only once, so all items will use the <em>same</em> sheet. It will be calculated by evaluating the expression for the <strong>first input item</strong>.',
|
||||
displayCondition:
|
||||
'={{ $rawParameter.sheetName?.startsWith("=") && $input.all().length > 1 }}',
|
||||
whenToDisplay: 'always',
|
||||
location: 'outputPane',
|
||||
},
|
||||
{
|
||||
type: 'info',
|
||||
message:
|
||||
'Note on using an expression for Document: It will be evaluated only once, so all items will use the <em>same</em> document. It will be calculated by evaluating the expression for the <strong>first input item</strong>.',
|
||||
displayCondition:
|
||||
'={{ $rawParameter.documentId?.startsWith("=") && $input.all().length > 1 }}',
|
||||
whenToDisplay: 'always',
|
||||
location: 'outputPane',
|
||||
},
|
||||
],
|
||||
credentials: [
|
||||
{
|
||||
name: 'googleApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['serviceAccount'],
|
||||
},
|
||||
},
|
||||
testedBy: 'googleApiCredentialTest',
|
||||
},
|
||||
{
|
||||
name: 'googleSheetsOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['oAuth2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
authentication,
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Document',
|
||||
value: 'spreadsheet',
|
||||
},
|
||||
{
|
||||
name: 'Sheet Within Document',
|
||||
value: 'sheet',
|
||||
},
|
||||
],
|
||||
default: 'sheet',
|
||||
},
|
||||
...sheet.descriptions,
|
||||
...spreadsheet.descriptions,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,875 @@
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INode,
|
||||
IPollFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeOperationError } from 'n8n-workflow';
|
||||
import { utils as xlsxUtils } from 'xlsx';
|
||||
|
||||
import type {
|
||||
ILookupValues,
|
||||
ISheetUpdateData,
|
||||
ResourceLocator,
|
||||
SheetCellDecoded,
|
||||
SheetRangeData,
|
||||
SheetRangeDecoded,
|
||||
SpreadSheetResponse,
|
||||
ValueInputOption,
|
||||
ValueRenderOption,
|
||||
} from './GoogleSheets.types';
|
||||
import { getSheetId, removeEmptyColumns } from './GoogleSheets.utils';
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export class GoogleSheet {
|
||||
id: string;
|
||||
|
||||
executeFunctions: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions;
|
||||
|
||||
constructor(
|
||||
spreadsheetId: string,
|
||||
executeFunctions: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
) {
|
||||
this.executeFunctions = executeFunctions;
|
||||
this.id = spreadsheetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the range that also none latin character work
|
||||
*
|
||||
* @param {string} range
|
||||
* @returns {string}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
private encodeRange(range: string): string {
|
||||
if (range.includes('!')) {
|
||||
const [sheet, ranges] = range.split('!');
|
||||
return `${encodeURIComponent(sheet)}!${ranges}`;
|
||||
}
|
||||
// Use '' so that sheet is not interpreted as range
|
||||
return encodeURIComponent(`'${range}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears values from a sheet
|
||||
*
|
||||
* @param {string} range
|
||||
* @returns {Promise<object>}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
async clearData(range: string): Promise<object> {
|
||||
const body = {
|
||||
spreadsheetId: this.id,
|
||||
range,
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}:clear`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell values
|
||||
*/
|
||||
async getData(range: string, valueRenderMode: ValueRenderOption, dateTimeRenderOption?: string) {
|
||||
const query: IDataObject = {
|
||||
valueRenderOption: valueRenderMode,
|
||||
dateTimeRenderOption: 'FORMATTED_STRING',
|
||||
};
|
||||
|
||||
if (dateTimeRenderOption) {
|
||||
query.dateTimeRenderOption = dateTimeRenderOption;
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
|
||||
return response.values as string[][] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sheets in a Spreadsheet
|
||||
*/
|
||||
async spreadsheetGetSheets() {
|
||||
const query = {
|
||||
fields: 'sheets.properties',
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sheet within a spreadsheet based on name or ID
|
||||
*/
|
||||
async spreadsheetGetSheet(node: INode, mode: ResourceLocator, value: string) {
|
||||
const query = {
|
||||
fields: 'sheets.properties',
|
||||
};
|
||||
|
||||
const response = (await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}`,
|
||||
{},
|
||||
query,
|
||||
)) as SpreadSheetResponse;
|
||||
|
||||
const foundItem = response.sheets.find((item) => {
|
||||
if (mode === 'name') return item.properties.title === value;
|
||||
return item.properties.sheetId === getSheetId(value);
|
||||
});
|
||||
|
||||
if (!foundItem?.properties?.title) {
|
||||
const error = new Error(`Sheet with ${mode === 'name' ? 'name' : 'ID'} ${value} not found`);
|
||||
throw new NodeOperationError(node, error, { level: 'warning' });
|
||||
}
|
||||
|
||||
return foundItem.properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the grid properties of a sheet
|
||||
*/
|
||||
async getDataRange(sheetId: string) {
|
||||
const query = {
|
||||
fields: 'sheets.properties',
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
const foundItem = response.sheets.find(
|
||||
(item: { properties: { sheetId: string } }) => item.properties.sheetId === sheetId,
|
||||
);
|
||||
return foundItem.properties.gridProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets values in one or more ranges of a spreadsheet.
|
||||
*/
|
||||
async spreadsheetBatchUpdate(requests: IDataObject[]) {
|
||||
const body = {
|
||||
requests,
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}:batchUpdate`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cell values
|
||||
*/
|
||||
async batchUpdate(updateData: ISheetUpdateData[], valueInputMode: ValueInputOption) {
|
||||
const body = {
|
||||
data: updateData,
|
||||
valueInputOption: valueInputMode,
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values:batchUpdate`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async appendEmptyRowsOrColumns(sheetId: string, rowsToAdd = 1, columnsToAdd = 1) {
|
||||
const requests: IDataObject[] = [];
|
||||
|
||||
if (rowsToAdd > 0) {
|
||||
requests.push({
|
||||
appendDimension: {
|
||||
sheetId,
|
||||
dimension: 'ROWS',
|
||||
length: rowsToAdd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (columnsToAdd > 0) {
|
||||
requests.push({
|
||||
appendDimension: {
|
||||
sheetId,
|
||||
dimension: 'COLUMNS',
|
||||
length: columnsToAdd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (requests.length === 0) {
|
||||
throw new ApplicationError('Must specify at least one column or row to add', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}:batchUpdate`,
|
||||
{ requests },
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the cell values
|
||||
*/
|
||||
async appendData(
|
||||
range: string,
|
||||
data: string[][],
|
||||
valueInputMode: ValueInputOption,
|
||||
lastRow?: number,
|
||||
useAppend?: boolean,
|
||||
) {
|
||||
const lastRowWithData =
|
||||
lastRow ||
|
||||
(((await this.getData(range, 'UNFORMATTED_VALUE')) as string[][]) || []).length + 1;
|
||||
|
||||
const response = await this.updateRows(
|
||||
range,
|
||||
data,
|
||||
valueInputMode,
|
||||
lastRowWithData,
|
||||
data.length,
|
||||
useAppend,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async updateRows(
|
||||
sheetName: string,
|
||||
data: string[][],
|
||||
valueInputMode: ValueInputOption,
|
||||
row: number,
|
||||
rowsLength?: number,
|
||||
useAppend?: boolean,
|
||||
) {
|
||||
const [name, _sheetRange] = sheetName.split('!');
|
||||
const range = `${name}!${row}:${rowsLength ? row + rowsLength - 1 : row}`;
|
||||
|
||||
const body = {
|
||||
range,
|
||||
values: data,
|
||||
};
|
||||
|
||||
const query = {
|
||||
valueInputOption: valueInputMode,
|
||||
};
|
||||
|
||||
let response;
|
||||
|
||||
if (useAppend) {
|
||||
response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}:append`,
|
||||
body,
|
||||
query,
|
||||
);
|
||||
} else {
|
||||
response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'PUT',
|
||||
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}`,
|
||||
body,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given sheet data in a structured way
|
||||
*/
|
||||
convertSheetDataArrayToObjectArray(
|
||||
sheet: SheetRangeData,
|
||||
startRow: number,
|
||||
columnKeys: string[],
|
||||
addEmpty?: boolean,
|
||||
includeHeadersWithEmptyCells?: boolean,
|
||||
): IDataObject[] {
|
||||
const returnData = [];
|
||||
|
||||
for (let rowIndex = startRow; rowIndex < sheet.length; rowIndex++) {
|
||||
const item: IDataObject = {};
|
||||
|
||||
const rowCount = sheet[rowIndex].length;
|
||||
const columnCount = includeHeadersWithEmptyCells ? columnKeys.length : rowCount;
|
||||
|
||||
for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
|
||||
const key = columnKeys[columnIndex];
|
||||
if (key) {
|
||||
item[key] = sheet[rowIndex][columnIndex] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(item).length || addEmpty === true) {
|
||||
returnData.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given sheet data in a structured way using
|
||||
* the startRow as the one with the name of the key
|
||||
*/
|
||||
structureArrayDataByColumn(
|
||||
inputData: string[][],
|
||||
keyRow: number,
|
||||
dataStartRow: number,
|
||||
includeHeadersWithEmptyCells?: boolean,
|
||||
): IDataObject[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
if (keyRow < 0 || dataStartRow < keyRow || keyRow >= inputData.length) {
|
||||
// The key row does not exist so it is not possible to structure data
|
||||
return [];
|
||||
}
|
||||
|
||||
const longestRow = inputData.reduce((a, b) => (a.length > b.length ? a : b), []).length;
|
||||
for (let columnIndex = 0; columnIndex < longestRow; columnIndex++) {
|
||||
keys.push(inputData[keyRow][columnIndex] || `col_${columnIndex}`);
|
||||
}
|
||||
|
||||
return this.convertSheetDataArrayToObjectArray(
|
||||
inputData,
|
||||
dataStartRow,
|
||||
keys,
|
||||
false,
|
||||
includeHeadersWithEmptyCells,
|
||||
);
|
||||
}
|
||||
|
||||
testFilter(inputData: string[][], keyRow: number, dataStartRow: number): string[] {
|
||||
const keys: string[] = [];
|
||||
//const returnData = [];
|
||||
|
||||
if (keyRow < 0 || dataStartRow < keyRow || keyRow >= inputData.length) {
|
||||
// The key row does not exist so it is not possible to structure data
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create the keys array
|
||||
for (let columnIndex = 0; columnIndex < inputData[keyRow].length; columnIndex++) {
|
||||
keys.push(inputData[keyRow][columnIndex]);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
async appendSheetData({
|
||||
inputData,
|
||||
range,
|
||||
keyRowIndex,
|
||||
valueInputMode,
|
||||
usePathForKeyRow,
|
||||
columnNamesList,
|
||||
lastRow,
|
||||
useAppend,
|
||||
}: {
|
||||
inputData: IDataObject[];
|
||||
range: string;
|
||||
keyRowIndex: number;
|
||||
valueInputMode: ValueInputOption;
|
||||
usePathForKeyRow?: boolean;
|
||||
columnNamesList?: string[][];
|
||||
lastRow?: number;
|
||||
useAppend?: boolean;
|
||||
}): Promise<string[][]> {
|
||||
const data = await this.convertObjectArrayToSheetDataArray(
|
||||
inputData,
|
||||
range,
|
||||
keyRowIndex,
|
||||
usePathForKeyRow,
|
||||
columnNamesList,
|
||||
useAppend ? null : '',
|
||||
);
|
||||
return await this.appendData(range, data, valueInputMode, lastRow, useAppend);
|
||||
}
|
||||
|
||||
getColumnWithOffset(startColumn: string, offset: number): string {
|
||||
const columnIndex = xlsxUtils.decode_col(startColumn) + offset;
|
||||
return xlsxUtils.encode_col(columnIndex);
|
||||
}
|
||||
|
||||
async getColumnValues({
|
||||
range,
|
||||
keyIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
sheetData,
|
||||
}: {
|
||||
range: string;
|
||||
keyIndex: number;
|
||||
dataStartRowIndex: number;
|
||||
valueRenderMode: ValueRenderOption;
|
||||
sheetData?: string[][];
|
||||
}): Promise<string[]> {
|
||||
let columnValuesList;
|
||||
if (sheetData) {
|
||||
columnValuesList = sheetData.slice(dataStartRowIndex - 1).map((row) => row[keyIndex]);
|
||||
} else {
|
||||
const decodedRange = this.getDecodedSheetRange(range);
|
||||
const startRowIndex = decodedRange.start?.row || dataStartRowIndex;
|
||||
const endRowIndex = decodedRange.end?.row || '';
|
||||
|
||||
const keyColumn = this.getColumnWithOffset(decodedRange.start?.column || 'A', keyIndex);
|
||||
const keyColumnRange = `${decodedRange.name}!${keyColumn}${startRowIndex}:${keyColumn}${endRowIndex}`;
|
||||
columnValuesList = await this.getData(keyColumnRange, valueRenderMode);
|
||||
}
|
||||
|
||||
if (columnValuesList === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
'Could not retrieve the data from key column',
|
||||
);
|
||||
}
|
||||
|
||||
//Remove the first row which contains the key and flaten the array
|
||||
return columnValuesList.splice(1).flatMap((value) => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates data in a sheet
|
||||
*
|
||||
* @param {IDataObject[]} inputData Data to update Sheet with
|
||||
* @param {string} indexKey The name of the key which gets used to know which rows to update
|
||||
* @param {string} range The range to look for data
|
||||
* @param {number} keyRowIndex Index of the row which contains the keys
|
||||
* @param {number} dataStartRowIndex Index of the first row which contains data
|
||||
* @returns {Promise<string[][]>}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
async prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey,
|
||||
range,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
upsert = false,
|
||||
columnNamesList,
|
||||
columnValuesList,
|
||||
}: {
|
||||
inputData: IDataObject[];
|
||||
indexKey: string;
|
||||
range: string;
|
||||
keyRowIndex: number;
|
||||
dataStartRowIndex: number;
|
||||
valueRenderMode: ValueRenderOption;
|
||||
upsert?: boolean;
|
||||
columnNamesList?: string[][];
|
||||
columnValuesList?: string[];
|
||||
}) {
|
||||
const decodedRange = this.getDecodedSheetRange(range);
|
||||
const keyRowRange = `${decodedRange.name}!${decodedRange.start?.column || ''}${keyRowIndex + 1}:${decodedRange.end?.column || ''}${keyRowIndex + 1}`;
|
||||
|
||||
const sheetDatakeyRow = columnNamesList || (await this.getData(keyRowRange, valueRenderMode));
|
||||
|
||||
if (sheetDatakeyRow === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
'Could not retrieve the key row',
|
||||
);
|
||||
}
|
||||
|
||||
const columnNames = sheetDatakeyRow[0];
|
||||
|
||||
const keyIndex = columnNames.indexOf(indexKey);
|
||||
|
||||
if (keyIndex === -1 && !upsert) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`Could not find column for key "${indexKey}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const columnValues: Array<string | number> =
|
||||
columnValuesList ||
|
||||
(await this.getColumnValues({ range, keyIndex, dataStartRowIndex, valueRenderMode }));
|
||||
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
const appendData: IDataObject[] = [];
|
||||
|
||||
const getKeyIndex = (key: string | number, data: Array<string | number>) => {
|
||||
let index = -1;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i]?.toString() === key.toString()) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
for (const item of inputData) {
|
||||
const inputIndexKey = item[indexKey] as string;
|
||||
|
||||
if (inputIndexKey === undefined || inputIndexKey === null) {
|
||||
// Item does not have the indexKey so we can ignore it or append it if upsert true
|
||||
if (upsert) {
|
||||
appendData.push(item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Item does have the key so check if it exists in Sheet
|
||||
const indexOfIndexKeyInSheet = getKeyIndex(inputIndexKey, columnValues);
|
||||
|
||||
if (indexOfIndexKeyInSheet === -1) {
|
||||
// Key does not exist in the Sheet so it can not be updated so skip it or append it if upsert true
|
||||
if (upsert) {
|
||||
appendData.push(item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the row index in which the data should be updated
|
||||
const updateRowIndex = indexOfIndexKeyInSheet + dataStartRowIndex + 1;
|
||||
|
||||
// Check all the properties in the sheet and check which ones exist on the
|
||||
// item and should be updated
|
||||
for (const name of columnNames) {
|
||||
if (name === indexKey) {
|
||||
// Ignore the key itself as that does not get changed it gets
|
||||
// only used to find the correct row to update
|
||||
continue;
|
||||
}
|
||||
if (item[name] === undefined || item[name] === null) {
|
||||
// Property does not exist so skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
// Property exists so add it to the data to update
|
||||
// Get the column name in which the property data can be found
|
||||
const columnToUpdate = this.getColumnWithOffset(
|
||||
decodedRange.start?.column || 'A',
|
||||
columnNames.indexOf(name),
|
||||
);
|
||||
|
||||
let updateValue = item[name] as string;
|
||||
if (typeof updateValue === 'object') {
|
||||
try {
|
||||
updateValue = JSON.stringify(updateValue);
|
||||
} catch (error) {}
|
||||
}
|
||||
updateData.push({
|
||||
range: `${decodedRange.name}!${columnToUpdate}${updateRowIndex}`,
|
||||
values: [[updateValue]],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { updateData, appendData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates data in a sheet
|
||||
*
|
||||
* @param {IDataObject[]} inputData Data to update Sheet with
|
||||
* @param {string} range The range to look for data
|
||||
* @param {number} dataStartRowIndex Index of the first row which contains data
|
||||
* @param {string[][]} columnNamesList The column names to use
|
||||
* @returns {Promise<string[][]>}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
prepareDataForUpdatingByRowNumber(
|
||||
inputData: IDataObject[],
|
||||
range: string,
|
||||
columnNamesList: string[][],
|
||||
) {
|
||||
const decodedRange = this.getDecodedSheetRange(range);
|
||||
const columnNames = columnNamesList[0];
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
|
||||
for (const item of inputData) {
|
||||
const updateRowIndex = item.row_number as number;
|
||||
|
||||
for (const name of columnNames) {
|
||||
if (name === 'row_number') continue;
|
||||
if (item[name] === undefined || item[name] === null) continue;
|
||||
|
||||
const columnToUpdate = this.getColumnWithOffset(
|
||||
decodedRange.start?.column || 'A',
|
||||
columnNames.indexOf(name),
|
||||
);
|
||||
|
||||
let updateValue = item[name] as string;
|
||||
if (typeof updateValue === 'object') {
|
||||
try {
|
||||
updateValue = JSON.stringify(updateValue);
|
||||
} catch (error) {}
|
||||
}
|
||||
updateData.push({
|
||||
range: `${decodedRange.name}!${columnToUpdate}${updateRowIndex}`,
|
||||
values: [[updateValue]],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { updateData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for a specific value in a column and if it gets found it returns the whole row
|
||||
*
|
||||
* @param {string[][]} inputData Data to check for lookup value in
|
||||
* @param {number} keyRowIndex Index of the row which contains the keys
|
||||
* @param {number} dataStartRowIndex Index of the first row which contains data
|
||||
* @param {ILookupValues[]} lookupValues The lookup values which decide what data to return
|
||||
* @param {boolean} [returnAllMatches] Returns all the found matches instead of only the first one
|
||||
* @returns {Promise<IDataObject[]>}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
async lookupValues({
|
||||
inputData,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
lookupValues,
|
||||
returnAllMatches,
|
||||
nodeVersion,
|
||||
combineFilters = 'OR',
|
||||
}: {
|
||||
inputData: string[][];
|
||||
keyRowIndex: number;
|
||||
dataStartRowIndex: number;
|
||||
lookupValues: ILookupValues[];
|
||||
nodeVersion: number;
|
||||
returnAllMatches?: boolean;
|
||||
combineFilters?: 'AND' | 'OR';
|
||||
}): Promise<IDataObject[]> {
|
||||
const keys: string[] = [];
|
||||
|
||||
if (keyRowIndex < 0 || dataStartRowIndex < keyRowIndex || keyRowIndex >= inputData.length) {
|
||||
// The key row does not exist so it is not possible to look up the data
|
||||
throw new NodeOperationError(this.executeFunctions.getNode(), 'The key row does not exist');
|
||||
}
|
||||
|
||||
// Create the keys array
|
||||
for (let columnIndex = 0; columnIndex < inputData[keyRowIndex].length; columnIndex++) {
|
||||
keys.push(inputData[keyRowIndex][columnIndex] || `col_${columnIndex}`);
|
||||
}
|
||||
|
||||
// Standardize values array, if rows is [[]], map it to [['']] (Keep the columns into consideration)
|
||||
for (let rowIndex = 0; rowIndex < inputData?.length; rowIndex++) {
|
||||
if (inputData[rowIndex].length === 0) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
inputData[rowIndex][i] = '';
|
||||
}
|
||||
} else if (inputData[rowIndex].length < keys.length) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (inputData[rowIndex][i] === undefined) {
|
||||
inputData[rowIndex].push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop over all the lookup values and try to find a row to return
|
||||
let rowIndex: number;
|
||||
let returnColumnIndex: number;
|
||||
const addedRows: number[] = [];
|
||||
|
||||
// const returnData = [inputData[keyRowIndex]];
|
||||
const returnData = [keys];
|
||||
|
||||
if (combineFilters === 'OR') {
|
||||
lookupLoop: for (const lookupValue of lookupValues) {
|
||||
returnColumnIndex = keys.indexOf(lookupValue.lookupColumn);
|
||||
|
||||
if (returnColumnIndex === -1) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`The column "${lookupValue.lookupColumn}" could not be found`,
|
||||
);
|
||||
}
|
||||
|
||||
// Loop over all the items and find the one with the matching value
|
||||
for (rowIndex = dataStartRowIndex; rowIndex < inputData.length; rowIndex++) {
|
||||
if (
|
||||
inputData[rowIndex][returnColumnIndex]?.toString() ===
|
||||
lookupValue.lookupValue.toString()
|
||||
) {
|
||||
if (addedRows.indexOf(rowIndex) === -1) {
|
||||
returnData.push(inputData[rowIndex]);
|
||||
addedRows.push(rowIndex);
|
||||
}
|
||||
|
||||
if (returnAllMatches !== true) {
|
||||
if (nodeVersion >= 4.6) {
|
||||
break lookupLoop;
|
||||
}
|
||||
continue lookupLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lookupLoop: for (rowIndex = dataStartRowIndex; rowIndex < inputData.length; rowIndex++) {
|
||||
let allMatch = true;
|
||||
|
||||
for (const lookupValue of lookupValues) {
|
||||
returnColumnIndex = keys.indexOf(lookupValue.lookupColumn);
|
||||
|
||||
if (returnColumnIndex === -1) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`The column "${lookupValue.lookupColumn}" could not be found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
inputData[rowIndex][returnColumnIndex]?.toString() !==
|
||||
lookupValue.lookupValue.toString()
|
||||
) {
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allMatch) {
|
||||
if (addedRows.indexOf(rowIndex) === -1) {
|
||||
returnData.push(inputData[rowIndex]);
|
||||
addedRows.push(rowIndex);
|
||||
}
|
||||
|
||||
if (returnAllMatches !== true) {
|
||||
break lookupLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataWithoutEmptyColumns = removeEmptyColumns(returnData);
|
||||
return this.convertSheetDataArrayToObjectArray(
|
||||
dataWithoutEmptyColumns,
|
||||
1,
|
||||
dataWithoutEmptyColumns[0] as string[],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private async convertObjectArrayToSheetDataArray(
|
||||
inputData: IDataObject[],
|
||||
range: string,
|
||||
keyRowIndex: number,
|
||||
usePathForKeyRow?: boolean,
|
||||
columnNamesList?: string[][],
|
||||
emptyValue: string | null = '',
|
||||
): Promise<string[][]> {
|
||||
const decodedRange = this.getDecodedSheetRange(range);
|
||||
|
||||
const columnNamesRow =
|
||||
columnNamesList ||
|
||||
(await this.getData(
|
||||
`${decodedRange.name}!${keyRowIndex}:${keyRowIndex}`,
|
||||
'UNFORMATTED_VALUE',
|
||||
));
|
||||
|
||||
if (columnNamesRow === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
'Could not retrieve the column data',
|
||||
);
|
||||
}
|
||||
|
||||
const columnNames = columnNamesRow ? columnNamesRow[0] : [];
|
||||
const setData: string[][] = [];
|
||||
|
||||
inputData.forEach((item) => {
|
||||
const rowData: string[] = [];
|
||||
columnNames.forEach((key) => {
|
||||
let value;
|
||||
if (usePathForKeyRow) {
|
||||
value = get(item, key) as string;
|
||||
} else {
|
||||
value = item[key] as string;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
rowData.push(emptyValue as string);
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
rowData.push(JSON.stringify(value));
|
||||
} else {
|
||||
rowData.push(value);
|
||||
}
|
||||
});
|
||||
setData.push(rowData);
|
||||
});
|
||||
return setData;
|
||||
}
|
||||
|
||||
private getDecodedSheetRange(stringToDecode: string): SheetRangeDecoded {
|
||||
const decodedRange: IDataObject = {};
|
||||
const [name, range] = stringToDecode.split('!');
|
||||
|
||||
decodedRange.nameWithRange = stringToDecode;
|
||||
decodedRange.name = name;
|
||||
decodedRange.range = range || '';
|
||||
decodedRange.start = {};
|
||||
decodedRange.end = {};
|
||||
|
||||
if (range) {
|
||||
const [startCell, endCell] = range.split(':');
|
||||
if (startCell) {
|
||||
decodedRange.start = this.splitCellRange(startCell, range);
|
||||
}
|
||||
if (endCell) {
|
||||
decodedRange.end = this.splitCellRange(endCell, range);
|
||||
}
|
||||
}
|
||||
|
||||
return decodedRange as SheetRangeDecoded;
|
||||
}
|
||||
|
||||
private splitCellRange(cell: string, range: string): SheetCellDecoded {
|
||||
const cellData = cell.match(/([a-zA-Z]{1,10})([0-9]{0,10})/) || [];
|
||||
|
||||
if (cellData === null || cellData.length !== 3) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`The range "${range}" is not valid`,
|
||||
);
|
||||
}
|
||||
|
||||
return { cell: cellData[0], column: cellData[1], row: +cellData[2] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { AllEntities, Entity, PropertiesOf } from 'n8n-workflow';
|
||||
|
||||
export const ROW_NUMBER = 'row_number';
|
||||
|
||||
export interface ISheetOptions {
|
||||
scope: string[];
|
||||
}
|
||||
|
||||
export interface IGoogleAuthCredentials {
|
||||
email: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
export interface ISheetUpdateData {
|
||||
range: string;
|
||||
values: string[][];
|
||||
}
|
||||
|
||||
export interface ILookupValues {
|
||||
lookupColumn: string;
|
||||
lookupValue: string;
|
||||
}
|
||||
|
||||
export interface IToDeleteRange {
|
||||
amount: number;
|
||||
startIndex: number;
|
||||
sheetId: number;
|
||||
}
|
||||
|
||||
export interface IToDelete {
|
||||
[key: string]: IToDeleteRange[] | undefined;
|
||||
columns?: IToDeleteRange[];
|
||||
rows?: IToDeleteRange[];
|
||||
}
|
||||
|
||||
export type ValueInputOption = 'RAW' | 'USER_ENTERED';
|
||||
|
||||
export type ValueRenderOption = 'FORMATTED_VALUE' | 'FORMULA' | 'UNFORMATTED_VALUE';
|
||||
|
||||
export type RangeDetectionOptions = {
|
||||
rangeDefinition: 'detectAutomatically' | 'specifyRange' | 'specifyRangeA1';
|
||||
readRowsUntil?: 'firstEmptyRow' | 'lastRowInSheet';
|
||||
headerRow?: string;
|
||||
firstDataRow?: string;
|
||||
range?: string;
|
||||
};
|
||||
|
||||
export type SheetDataRow = Array<string | number>;
|
||||
export type SheetRangeData = SheetDataRow[];
|
||||
|
||||
// delete is del
|
||||
type GoogleSheetsMap = {
|
||||
spreadsheet: 'create' | 'deleteSpreadsheet';
|
||||
sheet: 'append' | 'clear' | 'create' | 'delete' | 'read' | 'remove' | 'update' | 'appendOrUpdate';
|
||||
};
|
||||
|
||||
export type GoogleSheets = AllEntities<GoogleSheetsMap>;
|
||||
|
||||
export type GoogleSheetsSpreadSheet = Entity<GoogleSheetsMap, 'spreadsheet'>;
|
||||
export type GoogleSheetsSheet = Entity<GoogleSheetsMap, 'sheet'>;
|
||||
|
||||
export type SpreadSheetProperties = PropertiesOf<GoogleSheetsSpreadSheet>;
|
||||
export type SheetProperties = PropertiesOf<GoogleSheetsSheet>;
|
||||
|
||||
export type ResourceLocator = 'id' | 'url' | 'list' | 'name';
|
||||
|
||||
export const ResourceLocatorUiNames = {
|
||||
id: 'By ID',
|
||||
url: 'By URL',
|
||||
list: 'From List',
|
||||
name: 'By Name',
|
||||
};
|
||||
|
||||
type SpreadSheetResponseSheet = {
|
||||
properties: {
|
||||
title: string;
|
||||
sheetId: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SpreadSheetResponse = {
|
||||
sheets: SpreadSheetResponseSheet[];
|
||||
};
|
||||
|
||||
export type SheetCellDecoded = {
|
||||
cell?: string;
|
||||
column?: string;
|
||||
row?: number;
|
||||
};
|
||||
|
||||
export type SheetRangeDecoded = {
|
||||
nameWithRange: string;
|
||||
name: string;
|
||||
range: string;
|
||||
start?: SheetCellDecoded;
|
||||
end?: SheetCellDecoded;
|
||||
};
|
||||
@@ -0,0 +1,369 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeListSearchItems,
|
||||
INodePropertyOptions,
|
||||
INode,
|
||||
ResourceMapperField,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { GoogleSheet } from './GoogleSheet';
|
||||
import type {
|
||||
RangeDetectionOptions,
|
||||
ResourceLocator,
|
||||
SheetRangeData,
|
||||
ValueInputOption,
|
||||
} from './GoogleSheets.types';
|
||||
import { ResourceLocatorUiNames, ROW_NUMBER } from './GoogleSheets.types';
|
||||
|
||||
export const untilSheetSelected = { sheetName: [''] };
|
||||
|
||||
// Used to extract the ID from the URL
|
||||
export function getSpreadsheetId(
|
||||
node: INode,
|
||||
documentIdType: ResourceLocator,
|
||||
value: string,
|
||||
): string {
|
||||
if (!value) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Can not get sheet '${ResourceLocatorUiNames[documentIdType]}' with a value of '${value}'`,
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
if (documentIdType === 'url') {
|
||||
const regex = /([-\w]{25,})/;
|
||||
const parts = value.match(regex);
|
||||
|
||||
if (parts == null || parts.length < 2) {
|
||||
return '';
|
||||
} else {
|
||||
return parts[0];
|
||||
}
|
||||
}
|
||||
// If it is byID or byList we can just return
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getSheetId(value: string): number {
|
||||
if (value === 'gid=0') return 0;
|
||||
return parseInt(value);
|
||||
}
|
||||
|
||||
// Convert number to Sheets / Excel column name
|
||||
export function getColumnName(colNumber: number): string {
|
||||
const baseChar = 'A'.charCodeAt(0);
|
||||
let letters = '';
|
||||
do {
|
||||
colNumber -= 1;
|
||||
letters = String.fromCharCode(baseChar + (colNumber % 26)) + letters;
|
||||
colNumber = (colNumber / 26) >> 0;
|
||||
} while (colNumber > 0);
|
||||
|
||||
return letters;
|
||||
}
|
||||
|
||||
// Convert Column Name to Number (A = 1, B = 2, AA = 27)
|
||||
export function getColumnNumber(colPosition: string): number {
|
||||
let colNum = 0;
|
||||
for (let i = 0; i < colPosition.length; i++) {
|
||||
colNum *= 26;
|
||||
colNum += colPosition[i].charCodeAt(0) - 'A'.charCodeAt(0) + 1;
|
||||
}
|
||||
return colNum;
|
||||
}
|
||||
|
||||
// Hex to RGB
|
||||
export function hexToRgb(hex: string) {
|
||||
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
||||
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||
hex = hex.replace(shorthandRegex, (_, r, g, b) => {
|
||||
return r + r + g + g + b + b;
|
||||
});
|
||||
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
red: parseInt(result[1], 16),
|
||||
green: parseInt(result[2], 16),
|
||||
blue: parseInt(result[3], 16),
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function addRowNumber(data: SheetRangeData, headerRow: number) {
|
||||
if (data.length === 0) return data;
|
||||
const sheetData = data.map((row, i) => [i + 1, ...row]);
|
||||
sheetData[headerRow][0] = ROW_NUMBER;
|
||||
return sheetData;
|
||||
}
|
||||
|
||||
export function trimToFirstEmptyRow(data: SheetRangeData, includesRowNumber = true) {
|
||||
const baseLength = includesRowNumber ? 1 : 0;
|
||||
const emptyRowIndex = data.findIndex((row) => row.slice(baseLength).every((cell) => cell === ''));
|
||||
if (emptyRowIndex === -1) {
|
||||
return data;
|
||||
}
|
||||
return data.slice(0, emptyRowIndex);
|
||||
}
|
||||
|
||||
export function removeEmptyRows(data: SheetRangeData, includesRowNumber = true) {
|
||||
const baseLength = includesRowNumber ? 1 : 0;
|
||||
const notEmptyRows = data.filter((row) =>
|
||||
row
|
||||
.slice(baseLength)
|
||||
.some((cell) => cell || typeof cell === 'number' || typeof cell === 'boolean'),
|
||||
);
|
||||
if (includesRowNumber) {
|
||||
notEmptyRows[0][0] = ROW_NUMBER;
|
||||
}
|
||||
return notEmptyRows;
|
||||
}
|
||||
|
||||
export function trimLeadingEmptyRows(
|
||||
data: SheetRangeData,
|
||||
includesRowNumber = true,
|
||||
rowNumbersColumnName = ROW_NUMBER,
|
||||
) {
|
||||
const baseLength = includesRowNumber ? 1 : 0;
|
||||
const firstNotEmptyRowIndex = data.findIndex((row) =>
|
||||
row.slice(baseLength).some((cell) => cell || typeof cell === 'number'),
|
||||
);
|
||||
|
||||
const returnData = data.slice(firstNotEmptyRowIndex);
|
||||
if (includesRowNumber) {
|
||||
returnData[0][0] = rowNumbersColumnName;
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function removeEmptyColumns(data: SheetRangeData) {
|
||||
if (!data || data.length === 0) return [];
|
||||
const returnData: SheetRangeData = [];
|
||||
const longestRow = data.reduce((a, b) => (a.length > b.length ? a : b), []).length;
|
||||
for (let col = 0; col < longestRow; col++) {
|
||||
const column = data.map((row) => row[col]);
|
||||
if (column[0] !== '') {
|
||||
returnData.push(column);
|
||||
continue;
|
||||
}
|
||||
const hasData = column.slice(1).some((cell) => cell || typeof cell === 'number');
|
||||
if (hasData) {
|
||||
returnData.push(column);
|
||||
}
|
||||
}
|
||||
return (returnData[0] || []).map((_, i) =>
|
||||
returnData.map((row) => (row[i] === undefined ? '' : row[i])),
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareSheetData(
|
||||
data: SheetRangeData,
|
||||
options: RangeDetectionOptions,
|
||||
addRowNumbersToData = true,
|
||||
) {
|
||||
let returnData: SheetRangeData = [...(data || [])];
|
||||
|
||||
let headerRow = 0;
|
||||
let firstDataRow = 1;
|
||||
|
||||
if (options.rangeDefinition === 'specifyRange') {
|
||||
headerRow = parseInt(options.headerRow as string, 10) - 1;
|
||||
firstDataRow = parseInt(options.firstDataRow as string, 10) - 1;
|
||||
}
|
||||
|
||||
if (addRowNumbersToData) {
|
||||
returnData = addRowNumber(returnData, headerRow);
|
||||
}
|
||||
|
||||
if (options.rangeDefinition === 'detectAutomatically') {
|
||||
returnData = removeEmptyColumns(returnData);
|
||||
returnData = trimLeadingEmptyRows(returnData, addRowNumbersToData);
|
||||
|
||||
if (options.readRowsUntil === 'firstEmptyRow') {
|
||||
returnData = trimToFirstEmptyRow(returnData, addRowNumbersToData);
|
||||
} else {
|
||||
returnData = removeEmptyRows(returnData, addRowNumbersToData);
|
||||
}
|
||||
}
|
||||
|
||||
return { data: returnData, headerRow, firstDataRow };
|
||||
}
|
||||
|
||||
export function getRangeString(sheetName: string, options: RangeDetectionOptions) {
|
||||
if (options.rangeDefinition === 'specifyRangeA1') {
|
||||
return options.range ? `${sheetName}!${options.range}` : sheetName;
|
||||
}
|
||||
return sheetName;
|
||||
}
|
||||
|
||||
export async function getExistingSheetNames(sheet: GoogleSheet) {
|
||||
const { sheets } = await sheet.spreadsheetGetSheets();
|
||||
return ((sheets as IDataObject[]) || []).map((entry) => (entry.properties as IDataObject)?.title);
|
||||
}
|
||||
|
||||
export function mapFields(this: IExecuteFunctions, inputSize: number) {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
for (let i = 0; i < inputSize; i++) {
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
if (nodeVersion < 4) {
|
||||
const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as IDataObject[];
|
||||
let dataToSend: IDataObject = {};
|
||||
for (const field of fields) {
|
||||
dataToSend = { ...dataToSend, [field.fieldId as string]: field.fieldValue };
|
||||
}
|
||||
returnData.push(dataToSend);
|
||||
} else {
|
||||
const mappingValues = this.getNodeParameter('columns.value', i) as IDataObject;
|
||||
if (Object.keys(mappingValues).length === 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
returnData.push(mappingValues);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export async function autoMapInputData(
|
||||
this: IExecuteFunctions,
|
||||
sheetNameWithRange: string,
|
||||
sheet: GoogleSheet,
|
||||
items: INodeExecutionData[],
|
||||
options: IDataObject,
|
||||
) {
|
||||
const returnData: IDataObject[] = [];
|
||||
const [sheetName, _sheetRange] = sheetNameWithRange.split('!');
|
||||
const locationDefine = (options.locationDefine as IDataObject)?.values as IDataObject;
|
||||
const handlingExtraData = (options.handlingExtraData as string) || 'insertInNewColumn';
|
||||
|
||||
let headerRow = 1;
|
||||
|
||||
if (locationDefine) {
|
||||
headerRow = parseInt(locationDefine.headerRow as string, 10);
|
||||
}
|
||||
|
||||
let columnNames: string[] = [];
|
||||
const response = await sheet.getData(`${sheetName}!${headerRow}:${headerRow}`, 'FORMATTED_VALUE');
|
||||
|
||||
columnNames = response ? response[0] : [];
|
||||
|
||||
if (handlingExtraData === 'insertInNewColumn') {
|
||||
if (!columnNames.length) {
|
||||
await sheet.updateRows(
|
||||
sheetName,
|
||||
[Object.keys(items[0].json).filter((key) => key !== ROW_NUMBER)],
|
||||
(options.cellFormat as ValueInputOption) || 'RAW',
|
||||
headerRow,
|
||||
);
|
||||
columnNames = Object.keys(items[0].json);
|
||||
}
|
||||
|
||||
const newColumns = new Set<string>();
|
||||
|
||||
items.forEach((item) => {
|
||||
Object.keys(item.json).forEach((key) => {
|
||||
if (key !== ROW_NUMBER && !columnNames.includes(key)) {
|
||||
newColumns.add(key);
|
||||
}
|
||||
});
|
||||
if (item.json[ROW_NUMBER]) {
|
||||
const { [ROW_NUMBER]: _, ...json } = item.json;
|
||||
returnData.push(json);
|
||||
return;
|
||||
}
|
||||
returnData.push(item.json);
|
||||
});
|
||||
if (newColumns.size) {
|
||||
await sheet.updateRows(
|
||||
sheetName,
|
||||
[columnNames.concat([...newColumns])],
|
||||
(options.cellFormat as ValueInputOption) || 'RAW',
|
||||
headerRow,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (handlingExtraData === 'ignoreIt') {
|
||||
items.forEach((item) => {
|
||||
returnData.push(item.json);
|
||||
});
|
||||
}
|
||||
if (handlingExtraData === 'error') {
|
||||
items.forEach((item, itemIndex) => {
|
||||
Object.keys(item.json).forEach((key) => {
|
||||
if (!columnNames.includes(key)) {
|
||||
throw new NodeOperationError(this.getNode(), 'Unexpected fields in node input', {
|
||||
itemIndex,
|
||||
description: `The input field '${key}' doesn't match any column in the Sheet. You can ignore this by changing the 'Handling extra data' field, which you can find under 'Options'.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
returnData.push(item.json);
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function sortLoadOptions(data: INodePropertyOptions[] | INodeListSearchItems[]) {
|
||||
const returnData = [...data];
|
||||
returnData.sort((a, b) => {
|
||||
const aName = a.name.toLowerCase();
|
||||
const bName = b.name.toLowerCase();
|
||||
if (aName < bName) {
|
||||
return -1;
|
||||
}
|
||||
if (aName > bName) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function cellFormatDefault(nodeVersion: number) {
|
||||
if (nodeVersion < 4.1) {
|
||||
return 'RAW';
|
||||
}
|
||||
return 'USER_ENTERED';
|
||||
}
|
||||
|
||||
export function checkForSchemaChanges(
|
||||
node: INode,
|
||||
columnNames: string[],
|
||||
schema: ResourceMapperField[],
|
||||
) {
|
||||
const updatedColumnNames: Array<{ oldName: string; newName: string }> = [];
|
||||
// RMC filters out empty columns so do the same here
|
||||
columnNames = columnNames.filter((col) => col !== '');
|
||||
|
||||
// if sheet does not contain ROW_NUMBER ignore it as data come from read rows operation
|
||||
const schemaColumns = columnNames.includes(ROW_NUMBER)
|
||||
? schema.map((s) => s.id)
|
||||
: schema.filter((s) => s.id !== ROW_NUMBER).map((s) => s.id);
|
||||
|
||||
for (const [columnIndex, columnName] of columnNames.entries()) {
|
||||
const schemaEntry = schemaColumns[columnIndex];
|
||||
if (schemaEntry === undefined) break;
|
||||
if (columnName !== schemaEntry) {
|
||||
updatedColumnNames.push({ oldName: schemaEntry, newName: columnName });
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedColumnNames.length) {
|
||||
throw new NodeOperationError(node, "Column names were updated after the node's setup", {
|
||||
description: `Refresh the columns list in the 'Column to Match On' parameter. Updated columns: ${updatedColumnNames.map((c) => `${c.oldName} -> ${c.newName}`).join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
INodeCredentialTestResult,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getGoogleAccessToken } from '../../../GenericFunctions';
|
||||
|
||||
export async function googleApiCredentialTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
try {
|
||||
const tokenRequest = await getGoogleAccessToken.call(this, credential.data!, 'sheetV2');
|
||||
if (!tokenRequest.access_token) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: 'Could not generate a token from your private key.',
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: `Private key validation failed: ${err.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * as loadOptions from './loadOptions';
|
||||
export * as listSearch from './listSearch';
|
||||
export * as credentialTest from './credentialTest';
|
||||
export * as resourceMapping from './resourceMapping';
|
||||
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchItems,
|
||||
INodeListSearchResult,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { ResourceLocator } from '../helpers/GoogleSheets.types';
|
||||
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export async function spreadSheetsSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const query: string[] = [];
|
||||
if (filter) {
|
||||
query.push(`name contains '${filter.replace("'", "\\'")}'`);
|
||||
}
|
||||
query.push("mimeType = 'application/vnd.google-apps.spreadsheet'");
|
||||
|
||||
const qs = {
|
||||
q: query.join(' and '),
|
||||
pageToken: (paginationToken as string) || undefined,
|
||||
fields: 'nextPageToken, files(id, name, webViewLink)',
|
||||
orderBy: 'modifiedByMeTime desc,name_natural',
|
||||
includeItemsFromAllDrives: true,
|
||||
supportsAllDrives: true,
|
||||
};
|
||||
|
||||
const res = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
'',
|
||||
{},
|
||||
qs,
|
||||
'https://www.googleapis.com/drive/v3/files',
|
||||
);
|
||||
return {
|
||||
results: res.files.map((sheet: IDataObject) => ({
|
||||
name: sheet.name as string,
|
||||
value: sheet.id as string,
|
||||
url: sheet.webViewLink as string,
|
||||
})),
|
||||
paginationToken: res.nextPageToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sheetsSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
_filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
|
||||
|
||||
if (!documentId) return { results: [] };
|
||||
|
||||
const { mode, value } = documentId;
|
||||
|
||||
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
|
||||
|
||||
const query = {
|
||||
fields: 'sheets.properties',
|
||||
};
|
||||
|
||||
const responseData = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${spreadsheetId}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
|
||||
if (responseData === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), 'No data got returned');
|
||||
}
|
||||
|
||||
const returnData: INodeListSearchItems[] = [];
|
||||
for (const sheet of responseData.sheets!) {
|
||||
if (sheet.properties!.sheetType !== 'GRID') {
|
||||
continue;
|
||||
}
|
||||
|
||||
returnData.push({
|
||||
name: sheet.properties!.title as string,
|
||||
value: (sheet.properties!.sheetId as number) || 'gid=0',
|
||||
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit#gid=${sheet.properties!.sheetId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { results: returnData };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { GoogleSheet } from '../helpers/GoogleSheet';
|
||||
import type { ResourceLocator } from '../helpers/GoogleSheets.types';
|
||||
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
|
||||
|
||||
export async function getSheets(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
|
||||
|
||||
if (!documentId) return [];
|
||||
|
||||
const { mode, value } = documentId;
|
||||
|
||||
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
|
||||
|
||||
const sheet = new GoogleSheet(spreadsheetId, this);
|
||||
const responseData = await sheet.spreadsheetGetSheets();
|
||||
|
||||
if (responseData === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), 'No data got returned');
|
||||
}
|
||||
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
for (const entry of responseData.sheets!) {
|
||||
if (entry.properties!.sheetType !== 'GRID') {
|
||||
continue;
|
||||
}
|
||||
|
||||
returnData.push({
|
||||
name: entry.properties!.title as string,
|
||||
value: entry.properties!.sheetId as unknown as string,
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export async function getSheetHeaderRow(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
|
||||
|
||||
if (!documentId) return [];
|
||||
|
||||
const { mode, value } = documentId;
|
||||
|
||||
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
|
||||
|
||||
const sheet = new GoogleSheet(spreadsheetId, this);
|
||||
const sheetWithinDocument = this.getNodeParameter('sheetName', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as {
|
||||
mode: ResourceLocator;
|
||||
};
|
||||
|
||||
const { title: sheetName } = await sheet.spreadsheetGetSheet(
|
||||
this.getNode(),
|
||||
sheetMode,
|
||||
sheetWithinDocument,
|
||||
);
|
||||
const sheetData = await sheet.getData(`${sheetName}!1:1`, 'FORMATTED_VALUE');
|
||||
|
||||
if (sheetData === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), 'No data got returned');
|
||||
}
|
||||
|
||||
const columns = sheet.testFilter(sheetData, 0, 0);
|
||||
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
returnData.push({
|
||||
name: column as unknown as string,
|
||||
value: column as unknown as string,
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export async function getSheetHeaderRowAndAddColumn(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData = await getSheetHeaderRow.call(this);
|
||||
returnData.push({
|
||||
name: 'New column ...',
|
||||
value: 'newColumn',
|
||||
});
|
||||
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', 0) as string;
|
||||
return returnData.filter((column) => column.value !== columnToMatchOn);
|
||||
}
|
||||
|
||||
export async function getSheetHeaderRowWithGeneratedColumnNames(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData = await getSheetHeaderRow.call(this);
|
||||
return returnData.map((column, i) => {
|
||||
if (column.value !== '') return column;
|
||||
const indexBasedValue = `col_${i + 1}`;
|
||||
return {
|
||||
name: indexBasedValue,
|
||||
value: indexBasedValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSheetHeaderRowAndSkipEmpty(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData = await getSheetHeaderRow.call(this);
|
||||
return returnData.filter((column) => column.value);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
ResourceMapperField,
|
||||
ResourceMapperFields,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { GoogleSheet } from '../helpers/GoogleSheet';
|
||||
import { ROW_NUMBER, type ResourceLocator } from '../helpers/GoogleSheets.types';
|
||||
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
|
||||
|
||||
export async function getMappingColumns(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<ResourceMapperFields> {
|
||||
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
|
||||
|
||||
if (!documentId) return { fields: [] };
|
||||
|
||||
const { mode, value } = documentId;
|
||||
|
||||
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
|
||||
|
||||
const sheet = new GoogleSheet(spreadsheetId, this);
|
||||
const sheetWithinDocument = this.getNodeParameter('sheetName', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as { mode: ResourceLocator };
|
||||
|
||||
const { title: sheetName } = await sheet.spreadsheetGetSheet(
|
||||
this.getNode(),
|
||||
sheetMode,
|
||||
sheetWithinDocument,
|
||||
);
|
||||
|
||||
const locationDefine = this.getNodeParameter(
|
||||
'options.locationDefine.values',
|
||||
0,
|
||||
{},
|
||||
) as IDataObject;
|
||||
|
||||
let columnNamesRow = 1;
|
||||
|
||||
if (locationDefine.headerRow) {
|
||||
columnNamesRow = locationDefine.headerRow as number;
|
||||
}
|
||||
|
||||
const sheetData = await sheet.getData(
|
||||
`${sheetName}!${columnNamesRow}:${columnNamesRow}`,
|
||||
'FORMATTED_VALUE',
|
||||
);
|
||||
|
||||
const columns = sheet.testFilter(sheetData || [], 0, 0).filter((col) => col !== '');
|
||||
|
||||
const fields: ResourceMapperField[] = columns.map((col) => ({
|
||||
id: col,
|
||||
displayName: col,
|
||||
required: false,
|
||||
defaultMatch: col === 'id',
|
||||
display: true,
|
||||
type: 'string',
|
||||
canBeUsedToMatch: true,
|
||||
}));
|
||||
|
||||
const operation = this.getNodeParameter('operation', 0) as string;
|
||||
|
||||
if (operation === 'update') {
|
||||
fields.push({
|
||||
id: ROW_NUMBER,
|
||||
displayName: ROW_NUMBER,
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'number',
|
||||
canBeUsedToMatch: true,
|
||||
readOnly: true,
|
||||
removed: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { fields };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import set from 'lodash/set';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
ILoadOptionsFunctions,
|
||||
IPollFunctions,
|
||||
IRequestOptions,
|
||||
JsonObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { getGoogleAccessToken } from '../../../GenericFunctions';
|
||||
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
resource: string,
|
||||
body: IDataObject = {},
|
||||
qs: IDataObject = {},
|
||||
uri?: string,
|
||||
headers: IDataObject = {},
|
||||
option: IDataObject = {},
|
||||
) {
|
||||
const authenticationMethod = this.getNodeParameter(
|
||||
'authentication',
|
||||
0,
|
||||
'serviceAccount',
|
||||
) as string;
|
||||
const options: IRequestOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method,
|
||||
body,
|
||||
qs,
|
||||
uri: uri || `https://sheets.googleapis.com${resource}`,
|
||||
json: true,
|
||||
...option,
|
||||
};
|
||||
try {
|
||||
if (Object.keys(headers).length !== 0) {
|
||||
options.headers = Object.assign({}, options.headers, headers);
|
||||
}
|
||||
if (Object.keys(body).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
if (authenticationMethod === 'serviceAccount') {
|
||||
const credentials = await this.getCredentials('googleApi');
|
||||
|
||||
const { access_token } = await getGoogleAccessToken.call(this, credentials, 'sheetV2');
|
||||
|
||||
options.headers!.Authorization = `Bearer ${access_token}`;
|
||||
|
||||
return await this.helpers.request(options);
|
||||
} else if (authenticationMethod === 'triggerOAuth2') {
|
||||
return await this.helpers.requestOAuth2.call(this, 'googleSheetsTriggerOAuth2Api', options);
|
||||
} else {
|
||||
return await this.helpers.requestOAuth2.call(this, 'googleSheetsOAuth2Api', options);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') {
|
||||
error.statusCode = '401';
|
||||
}
|
||||
|
||||
if (error instanceof NodeApiError) {
|
||||
if (error.message.includes('PERMISSION_DENIED')) {
|
||||
const details = error.description ? ` Details of the error: ${error.description}.` : '';
|
||||
const description = `Please check that the account you're using has the right permissions. (If you're trying to modify the sheet, you'll need edit access.)${details}`;
|
||||
|
||||
set(error, 'description', description);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequestAllItems(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
propertyName: string,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
query: IDataObject = {},
|
||||
uri?: string,
|
||||
) {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
query.maxResults = 100;
|
||||
const url = uri ? uri : `https://sheets.googleapis.com${method}`;
|
||||
do {
|
||||
responseData = await apiRequest.call(this, method, endpoint, body, query, url);
|
||||
query.pageToken = responseData.nextPageToken;
|
||||
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
|
||||
} while (responseData.nextPageToken !== undefined && responseData.nextPageToken !== '');
|
||||
|
||||
return returnData;
|
||||
}
|
||||
Reference in New Issue
Block a user