first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,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(', ')}`,
});
}
}