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,28 @@
import type {
IExecuteFunctions,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { router } from './actions/router';
import { versionDescription } from './actions/versionDescription';
import { listSearch, loadOptions } from './methods';
export class MicrosoftExcelV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
usableAsTool: true,
};
}
methods = { listSearch, loadOptions };
async execute(this: IExecuteFunctions) {
return await router.call(this);
}
}
@@ -0,0 +1,140 @@
import type { INodeProperties } from 'n8n-workflow';
export const workbookRLC: INodeProperties = {
displayName: 'Workbook',
name: 'workbook',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchWorkbooks',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '[a-zA-Z0-9]{2,}',
errorMessage: 'Not a valid Workbook ID',
},
},
],
},
],
};
export const worksheetRLC: INodeProperties = {
displayName: 'Sheet',
name: 'worksheet',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'getWorksheetsList',
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '{[a-zA-Z0-9\\-_]{2,}}',
errorMessage: 'Not a valid Sheet ID',
},
},
],
},
],
};
export const tableRLC: INodeProperties = {
displayName: 'Table',
name: 'table',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'getWorksheetTables',
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '{[a-zA-Z0-9\\-_]{2,}}',
errorMessage: 'Not a valid Table ID',
},
},
],
},
],
};
export const rawDataOutput: INodeProperties = {
displayName: 'Raw Data Output',
name: 'rawDataOutput',
type: 'fixedCollection',
default: { values: { rawData: false } },
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
// eslint-disable-next-line n8n-nodes-base/node-param-default-wrong-for-boolean
default: 0,
description:
'Whether the data should be returned RAW instead of parsed into keys according to their header',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
required: true,
displayOptions: {
show: {
rawData: [true],
},
},
description: 'The name of the property into which to write the RAW data',
},
],
},
],
displayOptions: {
hide: {
'/dataMode': ['nothing'],
},
},
};
@@ -0,0 +1,20 @@
import type { AllEntities, Entity } from 'n8n-workflow';
type MicrosoftExcelMap = {
table:
| 'append'
| 'addTable'
| 'convertToRange'
| 'deleteTable'
| 'getColumns'
| 'getRows'
| 'lookup';
workbook: 'addWorksheet' | 'deleteWorkbook' | 'getAll';
worksheet: 'append' | 'clear' | 'deleteWorksheet' | 'getAll' | 'readRows' | 'update' | 'upsert';
};
export type MicrosoftExcel = AllEntities<MicrosoftExcelMap>;
export type MicrosoftExcelChannel = Entity<MicrosoftExcelMap, 'table'>;
export type MicrosoftExcelMessage = Entity<MicrosoftExcelMap, 'workbook'>;
export type MicrosoftExcelMember = Entity<MicrosoftExcelMap, 'worksheet'>;
@@ -0,0 +1,36 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { MicrosoftExcel } from './node.type';
import * as table from './table/Table.resource';
import * as workbook from './workbook/Workbook.resource';
import * as worksheet from './worksheet/Worksheet.resource';
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
let returnData: INodeExecutionData[] = [];
const resource = this.getNodeParameter<MicrosoftExcel>('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const microsoftExcel = {
resource,
operation,
} as MicrosoftExcel;
switch (microsoftExcel.resource) {
case 'table':
returnData = await table[microsoftExcel.operation].execute.call(this, items);
break;
case 'workbook':
returnData = await workbook[microsoftExcel.operation].execute.call(this, items);
break;
case 'worksheet':
returnData = await worksheet[microsoftExcel.operation].execute.call(this, items);
break;
default:
throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not known`);
}
return [returnData];
}
@@ -0,0 +1,77 @@
import type { INodeProperties } from 'n8n-workflow';
import * as addTable from './addTable.operation';
import * as append from './append.operation';
import * as convertToRange from './convertToRange.operation';
import * as deleteTable from './deleteTable.operation';
import * as getColumns from './getColumns.operation';
import * as getRows from './getRows.operation';
import * as lookup from './lookup.operation';
export { append, addTable, convertToRange, deleteTable, getColumns, getRows, lookup };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['table'],
},
},
options: [
{
name: 'Append',
value: 'append',
description: 'Add rows to the end of the table',
action: 'Append rows to table',
},
{
name: 'Convert to Range',
value: 'convertToRange',
description: 'Convert a table to a range',
action: 'Convert to range',
},
{
name: 'Create',
value: 'addTable',
description: 'Add a table based on range',
action: 'Create a table',
},
{
name: 'Delete',
value: 'deleteTable',
description: 'Delete a table',
action: 'Delete a table',
},
{
name: 'Get Columns',
value: 'getColumns',
description: 'Retrieve a list of table columns',
action: 'Get columns',
},
{
name: 'Get Rows',
value: 'getRows',
description: 'Retrieve a list of table rows',
action: 'Get rows',
},
{
name: 'Lookup',
value: 'lookup',
description: 'Look for rows that match a given value in a column',
action: 'Lookup a column',
},
],
default: 'append',
},
...append.description,
...addTable.description,
...convertToRange.description,
...deleteTable.description,
...getColumns.description,
...getRows.description,
...lookup.description,
];
@@ -0,0 +1,133 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
{
displayName: 'Select Range',
name: 'selectRange',
type: 'options',
options: [
{
name: 'Automatically',
value: 'auto',
description: 'The whole used range on the selected sheet will be converted into a table',
},
{
name: 'Manually',
value: 'manual',
description: 'Select a range that will be converted into a table',
},
],
default: 'auto',
},
{
displayName: 'Range',
name: 'range',
type: 'string',
default: '',
placeholder: 'A1:B2',
description: 'The range of cells that will be converted to a table',
displayOptions: {
show: {
selectRange: ['manual'],
},
},
},
{
displayName: 'Has Headers',
name: 'hasHeaders',
type: 'boolean',
default: true,
description:
'Whether the range has column labels. When this property set to false Excel will automatically generate header shifting the data down by one row.',
},
];
const displayOptions = {
show: {
resource: ['table'],
operation: ['addTable'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
//https://learn.microsoft.com/en-us/graph/api/worksheet-post-tables?view=graph-rest-1.0
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', i, undefined, {
extractValue: true,
}) as string;
const selectRange = this.getNodeParameter('selectRange', i) as string;
const hasHeaders = this.getNodeParameter('hasHeaders', i) as boolean;
let range = '';
if (selectRange === 'auto') {
const { address } = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
{
select: 'address',
},
);
range = address.split('!')[1];
} else {
range = this.getNodeParameter('range', i) as string;
}
const responseData = await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/add`,
{
address: range,
hasHeaders,
},
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,289 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { generatePairedItemData, processJsonInput, updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse } from '../../helpers/interfaces';
import { prepareOutput } from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
tableRLC,
{
displayName: 'Data Mode',
name: 'dataMode',
type: 'options',
default: 'define',
options: [
{
name: 'Auto-Map Input Data to Columns',
value: 'autoMap',
description: 'Use when node input properties match destination column names',
},
{
name: 'Map Each Column Below',
value: 'define',
description: 'Set the value for each destination column',
},
{
name: 'Raw',
value: 'raw',
description: 'Send raw data as JSON',
},
],
},
{
displayName: 'Data',
name: 'data',
type: 'json',
default: '',
required: true,
placeholder: 'e.g. [["Sara","1/2/2006","Berlin"],["George","5/3/2010","Paris"]]',
description: 'Raw values for the specified range as array of string arrays in JSON format',
displayOptions: {
show: {
dataMode: ['raw'],
},
},
},
{
displayName: 'Values to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
dataMode: ['define'],
},
},
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: ['table.value', 'worksheet.value', 'workbook.value'],
loadOptionsMethod: 'getTableColumns',
},
default: '',
},
{
displayName: 'Value',
name: 'fieldValue',
type: 'string',
default: '',
requiresDataPath: 'single',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Index',
name: 'index',
type: 'number',
default: 0,
typeOptions: {
minValue: 0,
},
description:
'Specifies the relative position of the new row. If not defined, the addition happens at the end. Any row below the inserted row will be shifted downwards. First row index is 0.',
},
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
// eslint-disable-next-line n8n-nodes-base/node-param-default-wrong-for-boolean
default: 0,
description:
'Whether the data should be returned RAW instead of parsed into keys according to their header',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
required: true,
displayOptions: {
show: {
rawData: [true],
},
},
description: 'The name of the property into which to write the RAW data',
},
],
},
];
const displayOptions = {
show: {
resource: ['table'],
operation: ['append'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
//https://docs.microsoft.com/en-us/graph/api/table-post-rows?view=graph-rest-1.0&tabs=http
const returnData: INodeExecutionData[] = [];
try {
// TODO: At some point it should be possible to use item dependent parameters.
// Is however important to then not make one separate request each.
const workbookId = this.getNodeParameter('workbook', 0, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', 0, undefined, {
extractValue: true,
}) as string;
const tableId = this.getNodeParameter('table', 0, undefined, {
extractValue: true,
}) as string;
const dataMode = this.getNodeParameter('dataMode', 0) as string;
// Get table columns to eliminate any columns not needed on the input
const columnsData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
);
const columnsRow = columnsData.value.map((column: IDataObject) => column.name);
const body: IDataObject = {};
let values: string[][] = [];
if (dataMode === 'raw') {
const data = this.getNodeParameter('data', 0);
values = processJsonInput(data, 'Data') as string[][];
}
if (dataMode === 'autoMap') {
const itemsData = items.map((item) => item.json);
for (const item of itemsData) {
const updateRow: string[] = [];
for (const column of columnsRow) {
updateRow.push(item[column] as string);
}
values.push(updateRow);
}
}
if (dataMode === 'define') {
const itemsData: IDataObject[] = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const updateData: IDataObject = {};
const definedFields = this.getNodeParameter('fieldsUi.values', itemIndex, []) as Array<{
column: string;
fieldValue: string;
}>;
for (const entry of definedFields) {
updateData[entry.column] = entry.fieldValue;
}
itemsData.push(updateData);
}
for (const item of itemsData) {
const updateRow: string[] = [];
for (const column of columnsRow) {
updateRow.push(item[column] as string);
}
values.push(updateRow);
}
}
body.values = values;
const options = this.getNodeParameter('options', 0);
if (options.index) {
body.index = options.index as number;
}
const { id } = await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/createSession`,
{ persistChanges: true },
);
const responseData = await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows/add`,
body,
{},
'',
{ 'workbook-session-id': id },
);
await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/closeSession`,
{},
{},
'',
{ 'workbook-session-id': id },
);
const rawData = options.rawData as boolean;
const dataProperty = (options.dataProperty as string) || 'data';
returnData.push(
...prepareOutput.call(this, this.getNode(), responseData as ExcelResponse, {
columnsRow,
dataProperty,
rawData,
}),
);
} catch (error) {
if (this.continueOnFail()) {
const itemData = generatePairedItemData(this.getInputData().length);
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData },
);
returnData.push(...executionErrorData);
} else {
throw error;
}
}
return returnData;
}
@@ -0,0 +1,70 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [workbookRLC, worksheetRLC, tableRLC];
const displayOptions = {
show: {
resource: ['table'],
operation: ['convertToRange'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', i, undefined, {
extractValue: true,
}) as string;
const tableId = this.getNodeParameter('table', i, undefined, {
extractValue: true,
}) as string;
const responseData = await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/convertToRange`,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,65 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [workbookRLC, worksheetRLC, tableRLC];
const displayOptions = {
show: {
resource: ['table'],
operation: ['deleteTable'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', i, undefined, {
extractValue: true,
}) as string;
const tableId = this.getNodeParameter('table', i, undefined, {
extractValue: true,
}) as string;
await microsoftApiRequest.call(
this,
'DELETE',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}`,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,171 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest, microsoftApiRequestAllItemsSkip } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
tableRLC,
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
default: false,
description:
'Whether the data should be returned RAW instead of parsed into keys according to their header',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
displayOptions: {
show: {
rawData: [true],
},
},
description: 'The name of the property into which to write the RAW data',
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
placeholder: 'Add Filter',
default: {},
displayOptions: {
show: {
rawData: [true],
},
},
options: [
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
description: 'A comma-separated list of the fields to include in the response',
},
],
},
];
const displayOptions = {
show: {
resource: ['table'],
operation: ['getColumns'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
//https://docs.microsoft.com/en-us/graph/api/table-list-columns?view=graph-rest-1.0&tabs=http
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const qs: IDataObject = {};
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', i, undefined, {
extractValue: true,
}) as string;
const tableId = this.getNodeParameter('table', i, undefined, {
extractValue: true,
}) as string;
const returnAll = this.getNodeParameter('returnAll', i);
const rawData = this.getNodeParameter('rawData', i);
if (rawData) {
const filters = this.getNodeParameter('filters', i);
if (filters.fields) {
qs.$select = filters.fields;
}
}
let responseData;
if (returnAll) {
responseData = await microsoftApiRequestAllItemsSkip.call(
this,
'value',
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
qs,
);
} else {
qs.$top = this.getNodeParameter('limit', i);
responseData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
qs,
);
responseData = responseData.value;
}
if (!rawData) {
responseData = responseData.map((column: IDataObject) => ({ name: column.name }));
} else {
const dataProperty = this.getNodeParameter('dataProperty', i) as string;
responseData = { [dataProperty]: responseData };
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,228 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest, microsoftApiRequestAllItemsSkip } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
tableRLC,
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
default: false,
description:
'Whether the data should be returned RAW instead of parsed into keys according to their header',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
displayOptions: {
show: {
rawData: [true],
},
},
description: 'The name of the property into which to write the RAW data',
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
placeholder: 'Add Filter',
default: {},
options: [
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
description: 'A comma-separated list of the fields to include in the response',
displayOptions: {
show: {
'/rawData': [true],
},
},
},
{
displayName: 'Column Names or IDs',
name: 'column',
type: 'multiOptions',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>. Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
typeOptions: {
loadOptionsDependsOn: ['table.value', 'worksheet.value', 'workbook.value'],
loadOptionsMethod: 'getTableColumns',
},
default: [],
displayOptions: {
show: {
'/rawData': [false],
},
},
},
],
},
];
const displayOptions = {
show: {
resource: ['table'],
operation: ['getRows'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
//https://docs.microsoft.com/en-us/graph/api/table-list-rows?view=graph-rest-1.0&tabs=http
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
const qs: IDataObject = {};
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', i, undefined, {
extractValue: true,
}) as string;
const tableId = this.getNodeParameter('table', i, undefined, {
extractValue: true,
}) as string;
const filters = this.getNodeParameter('filters', i);
const returnAll = this.getNodeParameter('returnAll', i);
const rawData = this.getNodeParameter('rawData', i);
if (rawData) {
if (filters.fields) {
qs.$select = filters.fields;
}
}
let responseData;
if (returnAll) {
responseData = await microsoftApiRequestAllItemsSkip.call(
this,
'value',
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows`,
{},
qs,
);
} else {
const rowsQs = { ...qs };
rowsQs.$top = this.getNodeParameter('limit', i);
responseData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows`,
{},
rowsQs,
);
responseData = responseData.value;
}
if (!rawData) {
const columnsQs = { ...qs };
columnsQs.$select = 'name';
// TODO: That should probably be cached in the future
let columns = await microsoftApiRequestAllItemsSkip.call(
this,
'value',
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
columnsQs,
);
columns = (columns as IDataObject[]).map((column) => column.name);
let rows: INodeExecutionData[] = [];
for (let index = 0; index < responseData.length; index++) {
const object: IDataObject = {};
for (let y = 0; y < columns.length; y++) {
object[columns[y]] = responseData[index].values[0][y];
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ ...object }),
{ itemData: { item: index } },
);
rows.push(...executionData);
}
if ((filters?.column as string[])?.length) {
rows = rows.map((row) => {
const rowData: IDataObject = {};
Object.keys(row.json).forEach((key) => {
if ((filters.column as string[]).includes(key)) {
rowData[key] = row.json[key];
}
});
return { ...rowData, json: rowData };
});
}
returnData.push(...rows);
} else {
const dataProperty = this.getNodeParameter('dataProperty', i) as string;
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ [dataProperty]: responseData }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,163 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequestAllItemsSkip } from '../../transport';
import { tableRLC, workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
tableRLC,
{
displayName: 'Lookup Column',
name: 'lookupColumn',
type: 'string',
default: '',
placeholder: 'Email',
required: true,
description: 'The name of the column in which to look for value',
},
{
displayName: 'Lookup Value',
name: 'lookupValue',
type: 'string',
default: '',
placeholder: 'frank@example.com',
required: true,
description: 'The value to look for in column',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Return All Matches',
name: 'returnAllMatches',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'By default only the first result gets returned. If options gets set all found matches get returned.',
},
],
},
];
const displayOptions = {
show: {
resource: ['table'],
operation: ['lookup'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
const qs: IDataObject = {};
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', i, undefined, {
extractValue: true,
}) as string;
const tableId = this.getNodeParameter('table', i, undefined, {
extractValue: true,
}) as string;
const lookupColumn = this.getNodeParameter('lookupColumn', i) as string;
const lookupValue = this.getNodeParameter('lookupValue', i) as string;
const options = this.getNodeParameter('options', i);
let responseData = await microsoftApiRequestAllItemsSkip.call(
this,
'value',
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/rows`,
{},
{},
);
qs.$select = 'name';
// TODO: That should probably be cached in the future
let columns = await microsoftApiRequestAllItemsSkip.call(
this,
'value',
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
qs,
);
columns = columns.map((column: IDataObject) => column.name);
if (!columns.includes(lookupColumn)) {
throw new NodeApiError(this.getNode(), responseData as JsonObject, {
message: `Column ${lookupColumn} does not exist on the table selected`,
});
}
const result: IDataObject[] = [];
for (let index = 0; index < responseData.length; index++) {
const object: IDataObject = {};
for (let y = 0; y < columns.length; y++) {
object[columns[y]] = responseData[index].values[0][y];
}
result.push({ ...object });
}
if (options.returnAllMatches) {
responseData = result.filter((data: IDataObject) => {
return data[lookupColumn]?.toString() === lookupValue;
});
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} else {
responseData = result.find((data: IDataObject) => {
return data[lookupColumn]?.toString() === lookupValue;
});
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,63 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
import * as table from './table/Table.resource';
import * as workbook from './workbook/Workbook.resource';
import * as worksheet from './worksheet/Worksheet.resource';
export const versionDescription: INodeTypeDescription = {
displayName: 'Microsoft Excel 365',
name: 'microsoftExcel',
icon: 'file:excel.svg',
group: ['input'],
version: [2, 2.1, 2.2],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Microsoft Excel API',
defaults: {
name: 'Microsoft Excel 365',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'microsoftExcelOAuth2Api',
required: true,
},
],
properties: [
{
displayName:
'This node connects to the Microsoft 365 cloud platform. Use the \'Extract from File\' and \'Convert to File\' nodes to directly manipulate spreadsheet files (.xls, .csv, etc). <a href="https://n8n.io/workflows/890-read-in-an-excel-spreadsheet-file/" target="_blank">More info</a>.',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Table',
value: 'table',
description: 'Represents an Excel table',
},
{
name: 'Workbook',
value: 'workbook',
description: 'A workbook is the top level object which contains one or more worksheets',
},
{
name: 'Sheet',
value: 'worksheet',
description: 'A sheet is a grid of cells which can contain data, tables, charts, etc',
},
],
default: 'workbook',
},
...table.description,
...workbook.description,
...worksheet.description,
],
};
@@ -0,0 +1,45 @@
import type { INodeProperties } from 'n8n-workflow';
import * as addWorksheet from './addWorksheet.operation';
import * as deleteWorkbook from './deleteWorkbook.operation';
import * as getAll from './getAll.operation';
export { addWorksheet, deleteWorkbook, getAll };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['workbook'],
},
},
options: [
{
name: 'Add Sheet',
value: 'addWorksheet',
description: 'Add a new sheet to the workbook',
action: 'Add a sheet to a workbook',
},
{
name: 'Delete',
value: 'deleteWorkbook',
description: 'Delete workbook',
action: 'Delete workbook',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get workbooks',
action: 'Get workbooks',
},
],
default: 'getAll',
},
...addWorksheet.description,
...deleteWorkbook.description,
...getAll.description,
];
@@ -0,0 +1,115 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
{
displayName: 'Options',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description:
'The name of the sheet to be added. The name should be unique. If not specified, Excel will determine the name of the new worksheet.',
},
],
},
];
const displayOptions = {
show: {
resource: ['workbook'],
operation: ['addWorksheet'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
//https://docs.microsoft.com/en-us/graph/api/worksheetcollection-add?view=graph-rest-1.0&tabs=http
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
const body: IDataObject = {};
if (additionalFields.name) {
body.name = additionalFields.name;
}
const { id } = await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/createSession`,
{ persistChanges: true },
);
const responseData = await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/worksheets/add`,
body,
{},
'',
{ 'workbook-session-id': id },
);
await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/closeSession`,
{},
{},
'',
{ 'workbook-session-id': id },
);
if (Array.isArray(responseData)) {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} else if (responseData !== undefined) {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,78 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC } from '../common.descriptions';
const properties: INodeProperties[] = [workbookRLC];
const displayOptions = {
show: {
resource: ['workbook'],
operation: ['deleteWorkbook'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
try {
await microsoftApiRequest.call(this, 'DELETE', `/drive/items/${workbookId}`);
} catch (error) {
if (error?.description.includes('Lock token does not match existing lock')) {
const errorDescription =
'Lock token does not match existing lock, this error could happen if the file is opened in the browser or the Office client, please close file and try again.';
throw new NodeOperationError(this.getNode(), error as Error, {
itemIndex: i,
description: errorDescription,
});
} else {
throw error;
}
}
const responseData = { success: true };
if (Array.isArray(responseData)) {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} else if (responseData !== undefined) {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,128 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest, microsoftApiRequestAllItems } from '../../transport';
const properties: INodeProperties[] = [
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
placeholder: 'Add Filter',
default: {},
options: [
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
description: 'A comma-separated list of the fields to include in the response',
},
],
},
];
const displayOptions = {
show: {
resource: ['workbook'],
operation: ['getAll'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const returnAll = this.getNodeParameter('returnAll', i);
const filters = this.getNodeParameter('filters', i);
const qs: IDataObject = {};
if (filters.fields) {
qs.$select = filters.fields;
}
let responseData;
if (returnAll) {
responseData = await microsoftApiRequestAllItems.call(
this,
'value',
'GET',
"/drive/root/search(q='.xlsx')",
{},
qs,
);
} else {
qs.$top = this.getNodeParameter('limit', i);
responseData = await microsoftApiRequest.call(
this,
'GET',
"/drive/root/search(q='.xlsx')",
{},
qs,
);
responseData = responseData.value;
}
if (Array.isArray(responseData)) {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} else if (responseData !== undefined) {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
}
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,79 @@
import type { INodeProperties } from 'n8n-workflow';
import * as append from './append.operation';
import * as clear from './clear.operation';
import * as deleteWorksheet from './deleteWorksheet.operation';
import * as getAll from './getAll.operation';
import * as readRows from './readRows.operation';
import * as update from './update.operation';
import * as upsert from './upsert.operation';
export { append, clear, deleteWorksheet, getAll, readRows, update, upsert };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['worksheet'],
},
},
options: [
{
name: 'Append',
value: 'append',
description: 'Append data to sheet',
action: 'Append data to sheet',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-option-name-wrong-for-upsert
name: 'Append or Update',
value: 'upsert',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-upsert
description: 'Append a new row or update the current one if it already exists (upsert)',
action: 'Append or update a sheet',
},
{
name: 'Clear',
value: 'clear',
description: 'Clear sheet',
action: 'Clear sheet',
},
{
name: 'Delete',
value: 'deleteWorksheet',
description: 'Delete sheet',
action: 'Delete sheet',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get a list of sheets',
action: 'Get sheets',
},
{
name: 'Get Rows',
value: 'readRows',
description: 'Retrieve a list of sheet rows',
action: 'Get rows from sheet',
},
{
name: 'Update',
value: 'update',
description: 'Update rows of a sheet or sheet range',
action: 'Update sheet',
},
],
default: 'getAll',
},
...append.description,
...clear.description,
...deleteWorksheet.description,
...getAll.description,
...readRows.description,
...update.description,
...upsert.description,
];
@@ -0,0 +1,271 @@
import {
NodeOperationError,
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import { processJsonInput, updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse } from '../../helpers/interfaces';
import { findAppendRange, prepareOutput } from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
{
displayName: 'Data Mode',
name: 'dataMode',
type: 'options',
default: 'define',
options: [
{
name: 'Auto-Map Input Data to Columns',
value: 'autoMap',
description: 'Use when node input properties match destination column names',
},
{
name: 'Map Each Column Below',
value: 'define',
description: 'Set the value for each destination column',
},
{
name: 'Raw',
value: 'raw',
description: 'Send raw data as JSON',
},
],
},
{
displayName: 'Data',
name: 'data',
type: 'json',
default: '',
required: true,
placeholder: 'e.g. [["Sara","1/2/2006","Berlin"],["George","5/3/2010","Paris"]]',
description: 'Raw values for the specified range as array of string arrays in JSON format',
displayOptions: {
show: {
dataMode: ['raw'],
},
},
},
{
displayName: 'Values to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
dataMode: ['define'],
},
},
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: ['worksheet.value'],
loadOptionsMethod: 'getWorksheetColumnRow',
},
default: '',
},
{
displayName: 'Value',
name: 'fieldValue',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
// eslint-disable-next-line n8n-nodes-base/node-param-default-wrong-for-boolean
default: 0,
description:
'Whether the data should be returned RAW instead of parsed into keys according to their header',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
required: true,
displayOptions: {
show: {
rawData: [true],
},
},
description: 'The name of the property into which to write the RAW data',
},
],
},
];
const displayOptions = {
show: {
resource: ['worksheet'],
operation: ['append'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
const nodeVersion = this.getNode().typeVersion;
const workbookId = this.getNodeParameter('workbook', 0, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', 0, undefined, {
extractValue: true,
}) as string;
const dataMode = this.getNodeParameter('dataMode', 0) as string;
const worksheetData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
);
let values: string[][] = [];
if (dataMode === 'raw') {
const data = this.getNodeParameter('data', 0);
values = processJsonInput(data, 'Data') as string[][];
const notArray = !values || !Array.isArray(values);
if (notArray) {
throw new NodeOperationError(this.getNode(), 'Data must be an array of arrays of strings');
}
const notStringArray =
values.some((item) => !Array.isArray(item)) ||
values.flat().some((item) => typeof item !== 'string');
if (notStringArray) {
throw new NodeOperationError(this.getNode(), 'Data must be an array of arrays of strings');
}
}
const isTableEmpty = !worksheetData.address.includes(':');
if (isTableEmpty && dataMode !== 'raw') {
throw new NodeOperationError(
this.getNode(),
'No data found in the specified range, mapping not possible, you can use raw mode instead to update selected range',
);
}
const columnsRow = (worksheetData.values as string[][])[0];
if (dataMode === 'autoMap') {
const itemsData = items.map((item) => item.json);
for (const item of itemsData) {
const updateRow: string[] = [];
for (const column of columnsRow) {
updateRow.push(item[column] as string);
}
values.push(updateRow);
}
}
if (dataMode === 'define') {
const itemsData: IDataObject[] = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const updateData: IDataObject = {};
const definedFields = this.getNodeParameter('fieldsUi.values', itemIndex, []) as Array<{
column: string;
fieldValue: string;
}>;
for (const entry of definedFields) {
updateData[entry.column] = entry.fieldValue;
}
itemsData.push(updateData);
}
for (const item of itemsData) {
const updateRow: string[] = [];
for (const column of columnsRow) {
updateRow.push(item[column] as string);
}
values.push(updateRow);
}
}
const { address } = worksheetData;
let range = '';
if (nodeVersion >= 2.2) {
range = findAppendRange(address, {
cols: values[0]?.length ?? 0,
rows: values?.length ?? 0,
});
} else {
// v2.1: incorrectly appends raw data, left for backward compatibility reasons
// if used range dimensions are smaller or bigger than inserted values dimensions, it will throw error
// Example: if used range is 2x4(cols x rows) and new data is 2x3, it will throw error "The number of rows or columns in the input array doesn't match the size or dimensions of the range."
const usedRange = address.split('!')[1];
const [rangeFrom, rangeTo] = usedRange.split(':');
const cellDataFrom = rangeFrom.match(/([a-zA-Z]{1,10})([0-9]{0,10})/) || [];
const cellDataTo = rangeTo.match(/([a-zA-Z]{1,10})([0-9]{0,10})/) || [];
const from = `${cellDataFrom[1]}${Number(cellDataTo[2]) + 1}`;
const to = `${cellDataTo[1]}${Number(cellDataTo[2]) + Number(values.length)}`;
range = `${from}:${to}`;
}
const responseData: ExcelResponse = await microsoftApiRequest.call(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values },
);
const rawData = this.getNodeParameter('options.rawData', 0, false) as boolean;
const dataProperty = this.getNodeParameter('options.dataProperty', 0, 'data') as string;
returnData.push(
...prepareOutput.call(this, this.getNode(), responseData, {
columnsRow,
dataProperty,
rawData,
}),
);
return returnData;
}
@@ -0,0 +1,122 @@
import type { INodeExecutionData, IExecuteFunctions, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
{
displayName: 'Apply To',
name: 'applyTo',
type: 'options',
//values in capital case as required by api
options: [
{
name: 'All',
value: 'All',
description: 'Clear data in cells and remove all formatting',
},
{
name: 'Formats',
value: 'Formats',
description: 'Clear formatting(e.g. font size, color) of cells',
},
{
name: 'Contents',
value: 'Contents',
description: 'Clear data contained in cells',
},
],
default: 'All',
},
{
displayName: 'Select a Range',
name: 'useRange',
type: 'boolean',
default: false,
},
{
displayName: 'Range',
name: 'range',
type: 'string',
displayOptions: {
show: {
useRange: [true],
},
},
placeholder: 'e.g. A1:B2',
default: '',
description: 'The sheet range that would be cleared, specified using a A1-style notation',
hint: 'Leave blank for entire worksheet',
},
];
const displayOptions = {
show: {
resource: ['worksheet'],
operation: ['clear'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', i, undefined, {
extractValue: true,
}) as string;
const applyTo = this.getNodeParameter('applyTo', i) as string;
const useRange = this.getNodeParameter('useRange', i, false) as boolean;
if (!useRange) {
await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range/clear`,
{ applyTo },
);
} else {
const range = this.getNodeParameter('range', i, '') as string;
await microsoftApiRequest.call(
this,
'POST',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')/clear`,
{ applyTo },
);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,61 @@
import type { INodeExecutionData, IExecuteFunctions, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [workbookRLC, worksheetRLC];
const displayOptions = {
show: {
resource: ['worksheet'],
operation: ['deleteWorksheet'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', i, undefined, {
extractValue: true,
}) as string;
await microsoftApiRequest.call(
this,
'DELETE',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}`,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ success: true }),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,125 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { microsoftApiRequest, microsoftApiRequestAllItems } from '../../transport';
import { workbookRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
placeholder: 'Add Filter',
default: {},
options: [
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
description: 'A comma-separated list of the fields to include in the response',
},
],
},
];
const displayOptions = {
show: {
resource: ['worksheet'],
operation: ['getAll'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
//https://docs.microsoft.com/en-us/graph/api/workbook-list-worksheets?view=graph-rest-1.0&tabs=http
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
const qs: IDataObject = {};
try {
const returnAll = this.getNodeParameter('returnAll', i);
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const filters = this.getNodeParameter('filters', i);
if (filters.fields) {
qs.$select = filters.fields;
}
let responseData;
if (returnAll) {
responseData = await microsoftApiRequestAllItems.call(
this,
'value',
'GET',
`/drive/items/${workbookId}/workbook/worksheets`,
{},
qs,
);
} else {
qs.$top = this.getNodeParameter('limit', i);
responseData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets`,
{},
qs,
);
responseData = responseData.value;
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,207 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse } from '../../helpers/interfaces';
import { checkRange, prepareOutput } from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
{
displayName: 'Select a Range',
name: 'useRange',
type: 'boolean',
default: false,
},
{
displayName: 'Range',
name: 'range',
type: 'string',
placeholder: 'e.g. A1:B2',
default: '',
description:
'The sheet range to read the data from specified using a A1-style notation, has to be specific e.g A1:B5, generic ranges like A:B are not supported',
hint: 'Leave blank to return entire sheet',
displayOptions: {
show: {
useRange: [true],
},
},
},
{
displayName: 'Header Row',
name: 'keyRow',
type: 'number',
typeOptions: {
minValue: 0,
},
default: 0,
hint: 'Index of the row which contains the column names',
description: "Relative to selected 'Range', first row index is 0",
displayOptions: {
show: {
useRange: [true],
},
},
},
{
displayName: 'First Data Row',
name: 'dataStartRow',
type: 'number',
typeOptions: {
minValue: 0,
},
default: 1,
hint: 'Index of first row which contains the actual data',
description: "Relative to selected 'Range', first row index is 0",
displayOptions: {
show: {
useRange: [true],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
// eslint-disable-next-line n8n-nodes-base/node-param-default-wrong-for-boolean
default: 0,
description:
'Whether the data should be returned RAW instead of parsed into keys according to their header',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
required: true,
displayOptions: {
show: {
rawData: [true],
},
},
description: 'The name of the property into which to write the RAW data',
},
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
description: 'Fields the response will containt. Multiple can be added separated by ,.',
displayOptions: {
show: {
rawData: [true],
},
},
},
],
},
];
const displayOptions = {
show: {
resource: ['worksheet'],
operation: ['readRows'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
//https://docs.microsoft.com/en-us/graph/api/worksheet-range?view=graph-rest-1.0&tabs=http
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
const qs: IDataObject = {};
try {
const workbookId = this.getNodeParameter('workbook', i, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', i, undefined, {
extractValue: true,
}) as string;
const options = this.getNodeParameter('options', i, {});
const range = this.getNodeParameter('range', i, '') as string;
checkRange(this.getNode(), range);
const rawData = (options.rawData as boolean) || false;
if (rawData && options.fields) {
qs.$select = options.fields;
}
let responseData;
if (range) {
responseData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{},
qs,
);
} else {
responseData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
{},
qs,
);
}
if (!rawData) {
const keyRow = this.getNodeParameter('keyRow', i, 0) as number;
const firstDataRow = this.getNodeParameter('dataStartRow', i, 1) as number;
returnData.push(
...prepareOutput.call(this, this.getNode(), responseData as ExcelResponse, {
rawData,
keyRow,
firstDataRow,
}),
);
} else {
const dataProperty = (options.dataProperty as string) || 'data';
returnData.push(
...prepareOutput.call(this, this.getNode(), responseData as ExcelResponse, {
rawData,
dataProperty,
}),
);
}
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return returnData;
}
@@ -0,0 +1,390 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { generatePairedItemData, processJsonInput, updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse, UpdateSummary } from '../../helpers/interfaces';
import {
checkRange,
prepareOutput,
updateByAutoMaping,
updateByDefinedValues,
} from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
{
displayName: 'Select a Range',
name: 'useRange',
type: 'boolean',
default: false,
},
{
displayName: 'Range',
name: 'range',
type: 'string',
displayOptions: {
show: {
dataMode: ['autoMap', 'define'],
useRange: [true],
},
},
placeholder: 'e.g. A1:B2',
default: '',
description:
'The sheet range to read the data from specified using a A1-style notation, has to be specific e.g A1:B5, generic ranges like A:B are not supported. Leave blank to use whole used range in the sheet.',
hint: 'First row must contain column names',
},
{
displayName: 'Range',
name: 'range',
type: 'string',
displayOptions: {
show: {
dataMode: ['raw'],
useRange: [true],
},
},
placeholder: 'e.g. A1:B2',
default: '',
description: 'The sheet range to read the data from specified using a A1-style notation',
hint: 'Leave blank for entire worksheet',
},
{
displayName: 'Data Mode',
name: 'dataMode',
type: 'options',
default: 'define',
options: [
{
name: 'Auto-Map Input Data to Columns',
value: 'autoMap',
description: 'Use when node input properties match destination column names',
},
{
name: 'Map Each Column Below',
value: 'define',
description: 'Set the value for each destination column',
},
{
name: 'Raw',
value: 'raw',
description:
'Send raw data as JSON, the whole selected range would be updated with the new values',
},
],
},
{
displayName: 'Data',
name: 'data',
type: 'json',
default: '',
required: true,
placeholder: 'e.g. [["Sara","1/2/2006","Berlin"],["George","5/3/2010","Paris"]]',
description:
'Raw values for the specified range as array of string arrays in JSON format. Should match the specified range: one array item for each row.',
displayOptions: {
show: {
dataMode: ['raw'],
},
},
},
{
// 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: ['worksheet.value', 'workbook.value', 'range'],
loadOptionsMethod: 'getWorksheetColumnRow',
},
default: '',
hint: "Used to find the correct row to update. Doesn't get changed.",
displayOptions: {
show: {
dataMode: ['autoMap', 'define'],
},
},
},
{
displayName: 'Value of Column to Match On',
name: 'valueToMatchOn',
type: 'string',
default: '',
displayOptions: {
show: {
dataMode: ['define'],
},
},
},
{
displayName: 'Values to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
dataMode: ['define'],
},
},
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: ['columnToMatchOn', 'range'],
loadOptionsMethod: 'getWorksheetColumnRowSkipColumnToMatchOn',
},
default: '',
},
{
displayName: 'Value',
name: 'fieldValue',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
// eslint-disable-next-line n8n-nodes-base/node-param-default-wrong-for-boolean
default: 0,
description:
'Whether the data should be returned RAW instead of parsed into keys according to their header',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
required: true,
displayOptions: {
show: {
rawData: [true],
},
},
description: 'The name of the property into which to write the RAW data',
},
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
description: 'Fields the response will containt. Multiple can be added separated by ,.',
displayOptions: {
show: {
rawData: [true],
},
},
},
{
displayName: 'Update All Matches',
name: 'updateAll',
type: 'boolean',
default: false,
description: 'Whether to update all matching rows or just the first match',
displayOptions: {
hide: {
'/dataMode': ['raw'],
},
},
},
],
},
];
const displayOptions = {
show: {
resource: ['worksheet'],
operation: ['update'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
try {
const options = this.getNodeParameter('options', 0, {});
const rawData = options.rawData as boolean;
const dataProperty = options.dataProperty ? (options.dataProperty as string) : 'data';
const qs: IDataObject = {};
if (rawData && options.fields) {
qs.$select = options.fields;
}
const workbookId = this.getNodeParameter('workbook', 0, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', 0, undefined, {
extractValue: true,
}) as string;
let range = this.getNodeParameter('range', 0, '') as string;
checkRange(this.getNode(), range);
const dataMode = this.getNodeParameter('dataMode', 0) as string;
let worksheetData: IDataObject = {};
if (range && dataMode !== 'raw') {
worksheetData = await microsoftApiRequest.call(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
);
}
//get used range if range not provided; if 'raw' mode fetch only address information
if (range === '') {
const query: IDataObject = {};
if (dataMode === 'raw') {
query.select = 'address';
}
worksheetData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
query,
);
range = (worksheetData.address as string).split('!')[1];
}
let responseData;
if (dataMode === 'raw') {
const data = this.getNodeParameter('data', 0);
const values = processJsonInput(data, 'Data') as string[][];
responseData = await microsoftApiRequest.call(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values },
qs,
);
returnData.push(
...prepareOutput.call(this, this.getNode(), responseData as ExcelResponse, {
rawData,
dataProperty,
}),
);
} else {
if (worksheetData.values === undefined || (worksheetData.values as string[][]).length <= 1) {
throw new NodeOperationError(
this.getNode(),
'No data found in the specified range, mapping not possible, you can use raw mode instead to update selected range',
);
}
const updateAll = this.getNodeParameter('options.updateAll', 0, false) as boolean;
let updateSummary: UpdateSummary = {
updatedData: [],
updatedRows: [],
appendData: [],
};
if (dataMode === 'define') {
updateSummary = updateByDefinedValues.call(
this,
items.length,
worksheetData.values as string[][],
updateAll,
);
}
if (dataMode === 'autoMap') {
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', 0) as string;
if (!items.some(({ json }) => json[columnToMatchOn] !== undefined)) {
throw new NodeOperationError(
this.getNode(),
`Any item in input data contains column '${columnToMatchOn}', that is selected to match on`,
);
}
updateSummary = updateByAutoMaping(
items,
worksheetData.values as string[][],
columnToMatchOn,
updateAll,
);
}
responseData = await microsoftApiRequest.call(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values: updateSummary.updatedData },
);
const { updatedRows } = updateSummary;
returnData.push(
...prepareOutput.call(this, this.getNode(), responseData as ExcelResponse, {
updatedRows,
rawData,
dataProperty,
}),
);
}
} catch (error) {
if (this.continueOnFail()) {
const itemData = generatePairedItemData(this.getInputData().length);
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData },
);
returnData.push(...executionErrorData);
} else {
throw error;
}
}
return returnData;
}
@@ -0,0 +1,397 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { generatePairedItemData, processJsonInput, updateDisplayOptions } from '@utils/utilities';
import type { ExcelResponse, UpdateSummary } from '../../helpers/interfaces';
import {
checkRange,
parseAddress,
prepareOutput,
updateByAutoMaping,
updateByDefinedValues,
} from '../../helpers/utils';
import { microsoftApiRequest } from '../../transport';
import { workbookRLC, worksheetRLC } from '../common.descriptions';
const properties: INodeProperties[] = [
workbookRLC,
worksheetRLC,
{
displayName: 'Select a Range',
name: 'useRange',
type: 'boolean',
default: false,
},
{
displayName: 'Range',
name: 'range',
type: 'string',
displayOptions: {
show: {
dataMode: ['autoMap', 'define'],
useRange: [true],
},
},
placeholder: 'e.g. A1:B2',
default: '',
description:
'The sheet range to read the data from specified using a A1-style notation, has to be specific e.g A1:B5, generic ranges like A:B are not supported. Leave blank to use whole used range in the sheet.',
hint: 'First row must contain column names',
},
{
displayName: 'Data Mode',
name: 'dataMode',
type: 'options',
default: 'define',
options: [
{
name: 'Auto-Map Input Data to Columns',
value: 'autoMap',
description: 'Use when node input properties match destination column names',
},
{
name: 'Map Each Column Below',
value: 'define',
description: 'Set the value for each destination column',
},
],
},
{
// 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: ['worksheet.value', 'workbook.value', 'range'],
loadOptionsMethod: 'getWorksheetColumnRow',
},
default: '',
hint: "Used to find the correct row to update. Doesn't get changed.",
displayOptions: {
show: {
dataMode: ['autoMap', 'define'],
},
},
},
{
displayName: 'Value of Column to Match On',
name: 'valueToMatchOn',
type: 'string',
default: '',
displayOptions: {
show: {
dataMode: ['define'],
},
},
},
{
displayName: 'Values to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
dataMode: ['define'],
},
},
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: ['columnToMatchOn', 'range'],
loadOptionsMethod: 'getWorksheetColumnRowSkipColumnToMatchOn',
},
default: '',
},
{
displayName: 'Value',
name: 'fieldValue',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Append After Selected Range',
name: 'appendAfterSelectedRange',
type: 'boolean',
default: false,
description: 'Whether to append data after the selected range or used range',
displayOptions: {
show: {
'/dataMode': ['autoMap', 'define'],
'/useRange': [true],
},
},
},
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
// eslint-disable-next-line n8n-nodes-base/node-param-default-wrong-for-boolean
default: 0,
description:
'Whether the data should be returned RAW instead of parsed into keys according to their header',
},
{
displayName: 'Data Property',
name: 'dataProperty',
type: 'string',
default: 'data',
required: true,
displayOptions: {
show: {
rawData: [true],
},
},
description: 'The name of the property into which to write the RAW data',
},
{
displayName: 'Update All Matches',
name: 'updateAll',
type: 'boolean',
default: false,
description: 'Whether to update all matching rows or just the first match',
},
],
},
];
const displayOptions = {
show: {
resource: ['worksheet'],
operation: ['upsert'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const returnData: INodeExecutionData[] = [];
const nodeVersion = this.getNode().typeVersion;
try {
const workbookId = this.getNodeParameter('workbook', 0, undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', 0, undefined, {
extractValue: true,
}) as string;
let range = this.getNodeParameter('range', 0, '') as string;
checkRange(this.getNode(), range);
const dataMode = this.getNodeParameter('dataMode', 0) as string;
let worksheetData: IDataObject = {};
if (range && dataMode !== 'raw') {
worksheetData = await microsoftApiRequest.call(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
);
}
//get used range if range not provided; if 'raw' mode fetch only address information
if (range === '') {
const query: IDataObject = {};
if (dataMode === 'raw') {
query.select = 'address';
}
worksheetData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
query,
);
range = (worksheetData.address as string).split('!')[1];
}
let responseData;
if (dataMode === 'raw') {
const data = this.getNodeParameter('data', 0);
const values = processJsonInput(data, 'Data') as string[][];
responseData = await microsoftApiRequest.call(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values },
);
}
if (
dataMode !== 'raw' &&
(worksheetData.values === undefined || (worksheetData.values as string[][]).length < 1)
) {
throw new NodeOperationError(
this.getNode(),
'No data found in the specified range, mapping not possible, you can use raw mode instead to update selected range',
);
}
const updateAll = this.getNodeParameter('options.updateAll', 0, false) as boolean;
let updateSummary: UpdateSummary = {
updatedData: [],
updatedRows: [],
appendData: [],
};
if (dataMode === 'define') {
updateSummary = updateByDefinedValues.call(
this,
items.length,
worksheetData.values as string[][],
updateAll,
);
}
if (dataMode === 'autoMap') {
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', 0) as string;
if (!items.some(({ json }) => json[columnToMatchOn] !== undefined)) {
throw new NodeOperationError(
this.getNode(),
`Any item in input data contains column '${columnToMatchOn}', that is selected to match on`,
);
}
updateSummary = updateByAutoMaping(
items,
worksheetData.values as string[][],
columnToMatchOn,
updateAll,
);
}
const appendAfterSelectedRange = this.getNodeParameter(
'options.appendAfterSelectedRange',
0,
false,
) as boolean;
//remove empty rows from the end
if (nodeVersion > 2 && !appendAfterSelectedRange && updateSummary.updatedData.length) {
for (let i = updateSummary.updatedData.length - 1; i >= 0; i--) {
if (
updateSummary.updatedData[i].every(
(item) => item === '' || item === undefined || item === null,
)
) {
updateSummary.updatedData.pop();
} else {
break;
}
}
}
if (updateSummary.appendData.length) {
const appendValues: string[][] = [];
const columnsRow = (worksheetData.values as string[][])[0];
for (const [index, item] of updateSummary.appendData.entries()) {
const updateRow: string[] = [];
for (const column of columnsRow) {
updateRow.push(item[column] as string);
}
appendValues.push(updateRow);
updateSummary.updatedRows.push(index + updateSummary.updatedData.length);
}
updateSummary.updatedData = updateSummary.updatedData.concat(appendValues);
const { cellFrom, cellTo } = parseAddress(range);
let lastRow = cellTo.row;
if (nodeVersion > 2 && !appendAfterSelectedRange) {
const { address } = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
{ select: 'address' },
);
const usedRange = parseAddress(address as string);
lastRow = usedRange.cellTo.row;
}
range = `${cellFrom.value}:${cellTo.column}${Number(lastRow) + appendValues.length}`;
}
responseData = await microsoftApiRequest.call(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ values: updateSummary.updatedData },
);
const { updatedRows } = updateSummary;
const rawData = this.getNodeParameter('options.rawData', 0, false) as boolean;
const dataProperty = this.getNodeParameter('options.dataProperty', 0, 'data') as string;
returnData.push(
...prepareOutput.call(this, this.getNode(), responseData as ExcelResponse, {
updatedRows,
rawData,
dataProperty,
}),
);
} catch (error) {
if (this.continueOnFail()) {
const itemData = generatePairedItemData(this.getInputData().length);
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData },
);
returnData.push(...executionErrorData);
} else {
throw error;
}
}
return returnData;
}
@@ -0,0 +1,14 @@
import type { IDataObject } from 'n8n-workflow';
export type SheetRow = Array<string | number | null>;
export type SheetData = SheetRow[];
export type ExcelResponse = {
values: SheetData;
};
export type UpdateSummary = {
updatedData: SheetData;
appendData: IDataObject[];
updatedRows: number[];
};
@@ -0,0 +1,319 @@
import type { IDataObject, IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { generatePairedItemData, wrapData } from '@utils/utilities';
import type { ExcelResponse, SheetData, UpdateSummary } from './interfaces';
export const CELL_REGEX = /([a-zA-Z]{1,10})([0-9]{0,10})/;
type PrepareOutputConfig = {
rawData: boolean;
dataProperty?: string;
keyRow?: number;
firstDataRow?: number;
columnsRow?: string[];
updatedRows?: number[];
};
export function prepareOutput(
this: IExecuteFunctions,
node: INode,
responseData: ExcelResponse,
config: PrepareOutputConfig,
) {
const returnData: INodeExecutionData[] = [];
const { rawData, keyRow, firstDataRow, columnsRow, updatedRows } = {
keyRow: 0,
firstDataRow: 1,
columnsRow: undefined,
updatedRows: undefined,
...config,
};
if (!rawData) {
let values = responseData.values;
if (values === null) {
throw new NodeOperationError(node, 'Operation did not return data');
}
let columns = [];
if (columnsRow?.length) {
columns = columnsRow;
values = [columns, ...values];
} else {
columns = values[keyRow];
}
if (updatedRows) {
values = values.filter((_, index) => updatedRows.includes(index));
}
for (let rowIndex = firstDataRow; rowIndex < values.length; rowIndex++) {
if (rowIndex === keyRow) continue;
const data: IDataObject = {};
for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
data[columns[columnIndex] as string] = values[rowIndex][columnIndex];
}
const executionData = this.helpers.constructExecutionMetaData(wrapData({ ...data }), {
itemData: { item: rowIndex },
});
returnData.push(...executionData);
}
} else {
const itemData = generatePairedItemData(this.getInputData().length);
const executionData = this.helpers.constructExecutionMetaData(
wrapData({ [config.dataProperty || 'data']: responseData }),
{ itemData },
);
returnData.push(...executionData);
}
return returnData;
}
// update values of spreadsheet when update mode is 'define'
export function updateByDefinedValues(
this: IExecuteFunctions,
itemslength: number,
sheetData: SheetData,
updateAllOccurences: boolean,
): UpdateSummary {
const [columns, ...originalValues] = sheetData;
const updateValues: SheetData = originalValues;
const updatedRowsIndexes = new Set<number>();
const appendData: IDataObject[] = [];
for (let itemIndex = 0; itemIndex < itemslength; itemIndex++) {
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', itemIndex) as string;
const valueToMatchOn = this.getNodeParameter('valueToMatchOn', itemIndex) as string;
const definedFields = this.getNodeParameter('fieldsUi.values', itemIndex, []) as Array<{
column: string;
fieldValue: string;
}>;
const columnToMatchOnIndex = columns.indexOf(columnToMatchOn);
const rowIndexes: number[] = [];
if (updateAllOccurences) {
for (const [index, row] of originalValues.entries()) {
if (
row[columnToMatchOnIndex] === valueToMatchOn ||
Number(row[columnToMatchOnIndex]) === Number(valueToMatchOn)
) {
rowIndexes.push(index);
}
}
} else {
const rowIndex = originalValues.findIndex(
(row) =>
row[columnToMatchOnIndex] === valueToMatchOn ||
Number(row[columnToMatchOnIndex]) === Number(valueToMatchOn),
);
if (rowIndex !== -1) {
rowIndexes.push(rowIndex);
}
}
if (!rowIndexes.length) {
const appendItem: IDataObject = {};
appendItem[columnToMatchOn] = valueToMatchOn;
for (const entry of definedFields) {
appendItem[entry.column] = entry.fieldValue;
}
appendData.push(appendItem);
continue;
}
for (const rowIndex of rowIndexes) {
for (const entry of definedFields) {
const columnIndex = columns.indexOf(entry.column);
if (rowIndex === -1) continue;
updateValues[rowIndex][columnIndex] = entry.fieldValue;
//add rows index and shift by 1 to account for header row
updatedRowsIndexes.add(rowIndex + 1);
}
}
}
const updatedData = [columns, ...updateValues];
const updatedRows = [0, ...Array.from(updatedRowsIndexes)];
const summary: UpdateSummary = { updatedData, appendData, updatedRows };
return summary;
}
// update values of spreadsheet when update mode is 'autoMap'
export function updateByAutoMaping(
items: INodeExecutionData[],
sheetData: SheetData,
columnToMatchOn: string,
updateAllOccurences = false,
): UpdateSummary {
const [columns, ...values] = sheetData;
const matchColumnIndex = columns.indexOf(columnToMatchOn);
const matchValuesMap = values.map((row) => row[matchColumnIndex]);
const updatedRowsIndexes = new Set<number>();
const appendData: IDataObject[] = [];
for (const { json } of items) {
const columnValue = json[columnToMatchOn] as string;
if (columnValue === undefined) continue;
const rowIndexes: number[] = [];
if (updateAllOccurences) {
matchValuesMap.forEach((value, index) => {
if (value === columnValue || Number(value) === Number(columnValue)) {
rowIndexes.push(index);
}
});
} else {
const rowIndex = matchValuesMap.findIndex(
(value) => value === columnValue || Number(value) === Number(columnValue),
);
if (rowIndex !== -1) rowIndexes.push(rowIndex);
}
if (!rowIndexes.length) {
appendData.push(json);
continue;
}
const updatedRow: Array<string | null> = [];
for (const columnName of columns as string[]) {
const updateValue = json[columnName] === undefined ? null : (json[columnName] as string);
updatedRow.push(updateValue);
}
for (const rowIndex of rowIndexes) {
values[rowIndex] = updatedRow as string[];
//add rows index and shift by 1 to account for header row
updatedRowsIndexes.add(rowIndex + 1);
}
}
const updatedData = [columns, ...values];
const updatedRows = [0, ...Array.from(updatedRowsIndexes)];
const summary: UpdateSummary = { updatedData, appendData, updatedRows };
return summary;
}
export const checkRange = (node: INode, range: string) => {
const rangeRegex = /^[A-Z]+:[A-Z]+$/i;
if (rangeRegex.test(range)) {
throw new NodeOperationError(
node,
`Specify the range more precisely e.g. A1:B5, generic ranges like ${range} are not supported`,
);
}
};
/**
* Parses strings like A1:B5 to Sheet!A1:B5 into detailed range information
* If the range does not have an end, it will be assumed to be the same as the start. E.g. A1 will be parsed as A1:A1
*/
export const parseAddress = (addressOrRange: string) => {
// remove sheet name
const range = addressOrRange.replace(/^.+!/, '');
const [rangeFrom, rangeTo] = range.split(':') as [string, string | undefined];
const cellFrom = rangeFrom.match(CELL_REGEX) ?? [];
if (cellFrom.length < 2) {
throw new Error(`Failed to parse range: ${addressOrRange}`);
}
const cellTo = (rangeTo ?? rangeFrom)?.match(CELL_REGEX) ?? [];
if (cellTo.length < 2) {
throw new Error(`Failed to parse range: ${addressOrRange}`);
}
return {
cellFrom: {
value: rangeFrom,
column: cellFrom[1],
row: cellFrom[2],
},
cellTo: {
value: rangeTo ?? rangeFrom,
column: cellTo[1],
row: cellTo[2],
},
};
};
/**
* Finds a next column in the sequence of columns in Excel
* Example:
* A -> B
* Z -> AA
*/
export const nextExcelColumn = (col: string, offset = 1) => {
if (offset < 0) {
throw new Error(`Invalid offset: ${offset}`);
}
if (offset === 0) {
return col;
}
const toNumber = (s: string) => {
return s.split('').reduce((acc, c) => acc * 26 + (c.charCodeAt(0) - 64), 0);
};
const toLetters = (n: number): string => {
if (n <= 26) {
return String.fromCharCode(64 + n);
} else {
const rem = ((n - 1) % 26) + 1;
const div = Math.floor((n - 1) / 26);
return toLetters(div) + String.fromCharCode(64 + rem);
}
};
const num = toNumber(col);
return toLetters(num + offset);
};
/**
* Accepts a used range and finds a new area under the used range.
* Changes the new area based on the number of columns and rows inserted.
* Example:
* A1:B2 -> A3:B4
*/
export const findAppendRange = (
usedRange: string,
{ cols, rows }: { cols: number; rows: number },
) => {
const { cellFrom, cellTo } = parseAddress(usedRange);
const isEmptyTable = cellFrom.value === cellTo.value;
// if table is empty we don't want to skip the first row
const rowOffset = isEmptyTable ? 0 : 1;
const startingCell = {
column: cellFrom.column,
row: Number(cellTo.row) + rowOffset,
};
const from = `${startingCell.column}${startingCell.row}`;
const nextColumn = nextExcelColumn(startingCell.column, Math.max(cols - 1, 0));
const to = `${nextColumn}${Number(startingCell.row) + Math.max(rows - 1, 0)}`;
return `${from}:${to}`;
};
@@ -0,0 +1,2 @@
export * as loadOptions from './loadOptions';
export * as listSearch from './listSearch';
@@ -0,0 +1,155 @@
import type {
IDataObject,
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
} from 'n8n-workflow';
import { microsoftApiRequest } from '../transport';
export async function searchWorkbooks(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const fileExtensions = ['.xlsx', '.xlsm', '.xlst'];
const extensionFilter = fileExtensions.join(' OR ');
const q = filter || extensionFilter;
let response: IDataObject = {};
if (paginationToken) {
response = await microsoftApiRequest.call(
this,
'GET',
'',
undefined,
undefined,
paginationToken, // paginationToken contains the full URL
);
} else {
response = await microsoftApiRequest.call(
this,
'GET',
`/drive/root/search(q='${q}')`,
undefined,
{
select: 'id,name,webUrl',
$top: 100,
},
);
}
if (response.value && filter) {
response.value = (response.value as IDataObject[]).filter((workbook: IDataObject) => {
return fileExtensions.some((extension) => (workbook.name as string).includes(extension));
});
}
return {
results: (response.value as IDataObject[]).map((workbook: IDataObject) => {
for (const extension of fileExtensions) {
if ((workbook.name as string).includes(extension)) {
workbook.name = (workbook.name as string).replace(extension, '');
break;
}
}
return {
name: workbook.name as string,
value: workbook.id as string,
url: workbook.webUrl as string,
};
}),
paginationToken: response['@odata.nextLink'],
};
}
export async function getWorksheetsList(
this: ILoadOptionsFunctions,
): Promise<INodeListSearchResult> {
const workbookRLC = this.getNodeParameter('workbook') as IDataObject;
const workbookId = workbookRLC.value as string;
let workbookURL = (workbookRLC.cachedResultUrl as string) ?? '';
if (workbookURL.includes('1drv.ms')) {
workbookURL = `https://onedrive.live.com/edit.aspx?resid=${workbookId}`;
}
let response: IDataObject = {};
response = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets`,
undefined,
{
select: 'id,name',
},
);
return {
results: (response.value as IDataObject[]).map((worksheet: IDataObject) => ({
name: worksheet.name as string,
value: worksheet.id as string,
url: workbookURL
? `${workbookURL}&activeCell=${encodeURIComponent(worksheet.name as string)}!A1`
: undefined,
})),
};
}
export async function getWorksheetTables(
this: ILoadOptionsFunctions,
): Promise<INodeListSearchResult> {
const workbookRLC = this.getNodeParameter('workbook') as IDataObject;
const workbookId = workbookRLC.value as string;
let workbookURL = (workbookRLC.cachedResultUrl as string) ?? '';
if (workbookURL.includes('1drv.ms')) {
workbookURL = `https://onedrive.live.com/edit.aspx?resid=${workbookId}`;
}
const worksheetId = this.getNodeParameter('worksheet', undefined, {
extractValue: true,
}) as string;
let response: IDataObject = {};
response = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables`,
undefined,
);
const results: INodeListSearchItems[] = [];
for (const table of response.value as IDataObject[]) {
const name = table.name as string;
const value = table.id as string;
const { address } = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${value}/range`,
undefined,
{
select: 'address',
},
);
const [sheetName, sheetRange] = address.split('!' as string);
let url;
if (workbookURL) {
url = `${workbookURL}&activeCell=${encodeURIComponent(sheetName as string)}${
sheetRange ? '!' + (sheetRange as string) : ''
}`;
}
results.push({ name, value, url });
}
return { results };
}
@@ -0,0 +1,88 @@
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import { microsoftApiRequest } from '../transport';
import { parseAddress } from '../helpers/utils';
export async function getWorksheetColumnRow(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const workbookId = this.getNodeParameter('workbook', undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', undefined, {
extractValue: true,
}) as string;
let range = this.getNodeParameter('range', '') as string;
let columns: string[] = [];
if (range === '') {
const worksheetData = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/usedRange`,
undefined,
{ select: 'values' },
);
columns = worksheetData.values[0] as string[];
} else {
const { cellFrom, cellTo } = parseAddress(range);
range = `${cellFrom.value}:${cellTo.column}${cellFrom.row}`;
const worksheetData = await microsoftApiRequest.call(
this,
'PATCH',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/range(address='${range}')`,
{ select: 'values' },
);
columns = worksheetData.values[0] as string[];
}
const returnData: INodePropertyOptions[] = [];
for (const column of columns) {
returnData.push({
name: column,
value: column,
});
}
return returnData;
}
export async function getWorksheetColumnRowSkipColumnToMatchOn(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData = await getWorksheetColumnRow.call(this);
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', 0) as string;
return returnData.filter((column) => column.value !== columnToMatchOn);
}
export async function getTableColumns(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const workbookId = this.getNodeParameter('workbook', undefined, {
extractValue: true,
}) as string;
const worksheetId = this.getNodeParameter('worksheet', undefined, {
extractValue: true,
}) as string;
const tableId = this.getNodeParameter('table', undefined, {
extractValue: true,
}) as string;
const response = await microsoftApiRequest.call(
this,
'GET',
`/drive/items/${workbookId}/workbook/worksheets/${worksheetId}/tables/${tableId}/columns`,
{},
);
return (response.value as IDataObject[]).map((column) => ({
name: column.name as string,
value: column.name as string,
}));
}
@@ -0,0 +1,210 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { microsoftApiRequest } from '../../transport/index';
describe('Microsoft Excel Transport', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockNode: INode;
let mockRequestOAuth2: jest.Mock;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockRequestOAuth2 = jest.fn();
mockExecuteFunctions.helpers.requestOAuth2 = mockRequestOAuth2;
mockNode = {
id: 'test-node',
name: 'Test Excel Node',
type: 'n8n-nodes-base.microsoftExcel',
typeVersion: 2,
position: [0, 0],
parameters: {},
};
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
jest.clearAllMocks();
});
afterEach(() => {
jest.resetAllMocks();
});
describe('microsoftApiRequest', () => {
describe('graphApiBaseUrl from credentials', () => {
it('should use base URL from credentials', async () => {
const mockResponse = { data: 'test' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
mockExecuteFunctions.getCredentials.mockResolvedValue({
oauthTokenData: {
access_token: 'test-access-token',
},
graphApiBaseUrl: 'https://graph.microsoft.us',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://graph.microsoft.us/v1.0/me/workbooks',
json: true,
}),
);
});
it('should fall back to default when credentials.graphApiBaseUrl is empty', async () => {
const mockResponse = { data: 'test' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
mockExecuteFunctions.getCredentials.mockResolvedValue({
oauthTokenData: {
access_token: 'test-access-token',
},
graphApiBaseUrl: '',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://graph.microsoft.com/v1.0/me/workbooks',
json: true,
}),
);
});
it('should fall back to default when credentials.graphApiBaseUrl is undefined', async () => {
const mockResponse = { data: 'test' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
mockExecuteFunctions.getCredentials.mockResolvedValue({
oauthTokenData: {
access_token: 'test-access-token',
},
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://graph.microsoft.com/v1.0/me/workbooks',
json: true,
}),
);
});
it('should strip trailing slashes from base URL using regex', async () => {
const mockResponse = { data: 'test' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
mockExecuteFunctions.getCredentials.mockResolvedValue({
oauthTokenData: {
access_token: 'test-access-token',
},
graphApiBaseUrl: 'https://graph.microsoft.com/',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://graph.microsoft.com/v1.0/me/workbooks',
json: true,
}),
);
});
it('should strip multiple trailing slashes from base URL', async () => {
const mockResponse = { data: 'test' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
mockExecuteFunctions.getCredentials.mockResolvedValue({
oauthTokenData: {
access_token: 'test-access-token',
},
graphApiBaseUrl: 'https://graph.microsoft.com///',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://graph.microsoft.com/v1.0/me/workbooks',
json: true,
}),
);
});
it('should use US Government cloud endpoint', async () => {
const mockResponse = { data: 'test' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
mockExecuteFunctions.getCredentials.mockResolvedValue({
oauthTokenData: {
access_token: 'test-access-token',
},
graphApiBaseUrl: 'https://graph.microsoft.us',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://graph.microsoft.us/v1.0/me/workbooks',
json: true,
}),
);
});
it('should use US Government DOD cloud endpoint', async () => {
const mockResponse = { data: 'test' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
mockExecuteFunctions.getCredentials.mockResolvedValue({
oauthTokenData: {
access_token: 'test-access-token',
},
graphApiBaseUrl: 'https://dod-graph.microsoft.us',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://dod-graph.microsoft.us/v1.0/me/workbooks',
json: true,
}),
);
});
it('should use China cloud endpoint', async () => {
const mockResponse = { data: 'test' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
mockExecuteFunctions.getCredentials.mockResolvedValue({
oauthTokenData: {
access_token: 'test-access-token',
},
graphApiBaseUrl: 'https://microsoftgraph.chinacloudapi.cn',
});
await microsoftApiRequest.call(mockExecuteFunctions, 'GET', '/workbooks');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'microsoftExcelOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://microsoftgraph.chinacloudapi.cn/v1.0/me/workbooks',
json: true,
}),
);
});
});
});
});
@@ -0,0 +1,93 @@
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
export async function microsoftApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
resource: string,
body: any = {},
qs: IDataObject = {},
uri?: string,
headers: IDataObject = {},
): Promise<any> {
const credentials = await this.getCredentials('microsoftExcelOAuth2Api');
const baseUrl = (
typeof credentials.graphApiBaseUrl === 'string' && credentials.graphApiBaseUrl !== ''
? credentials.graphApiBaseUrl
: 'https://graph.microsoft.com'
).replace(/\/+$/, '');
const options: IRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri || `${baseUrl}/v1.0/me${resource}`,
json: true,
};
try {
if (Object.keys(headers).length !== 0) {
options.headers = Object.assign({}, options.headers, headers);
}
return await this.helpers.requestOAuth2.call(this, 'microsoftExcelOAuth2Api', options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
export async function microsoftApiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
query: IDataObject = {},
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
let uri: string | undefined;
query.$top = 100;
do {
responseData = await microsoftApiRequest.call(this, method, endpoint, body, query, uri);
uri = responseData['@odata.nextLink'];
if (uri?.includes('$top')) {
delete query.$top;
}
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
} while (responseData['@odata.nextLink'] !== undefined);
return returnData;
}
export async function microsoftApiRequestAllItemsSkip(
this: IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
query: IDataObject = {},
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
query.$top = 100;
query.$skip = 0;
do {
responseData = await microsoftApiRequest.call(this, method, endpoint, body, query);
query.$skip += query.$top;
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
} while (responseData.value.length !== 0);
return returnData;
}