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,24 @@
{
"node": "n8n-nodes-base.spreadsheetFile",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Data & Storage", "Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.converttofile/"
}
],
"generic": [
{
"label": "Build your own virtual assistant with n8n: A step by step guide",
"icon": "👦",
"url": "https://n8n.io/blog/build-your-own-virtual-assistant-with-n8n-a-step-by-step-guide/"
}
]
},
"alias": ["_Excel", "Excel", "CSV", "Sheet", "Spreadsheet", "xls", "xlsx", "ods"],
"subcategories": {
"Core Nodes": ["Files"]
}
}
@@ -0,0 +1,26 @@
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { SpreadsheetFileV1 } from './v1/SpreadsheetFileV1.node';
import { SpreadsheetFileV2 } from './v2/SpreadsheetFileV2.node';
export class SpreadsheetFile extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
hidden: true,
displayName: 'Spreadsheet File',
name: 'spreadsheetFile',
icon: 'fa:table',
group: ['transform'],
description: 'Reads and writes data from a spreadsheet file like CSV, XLS, ODS, etc',
defaultVersion: 2,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new SpreadsheetFileV1(baseDescription),
2: new SpreadsheetFileV2(baseDescription),
};
super(nodeVersions, baseDescription);
}
}
@@ -0,0 +1,336 @@
import type { INodeProperties } from 'n8n-workflow';
export const operationProperty: INodeProperties = {
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Read From File',
value: 'fromFile',
description: 'Reads data from a spreadsheet file',
action: 'Read data from a spreadsheet file',
},
{
name: 'Write to File',
value: 'toFile',
description: 'Writes the workflow data to a spreadsheet file',
action: 'Write data to a spreadsheet file',
},
],
default: 'fromFile',
};
export const binaryProperty: INodeProperties = {
displayName: 'Input Binary Field',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
required: true,
placeholder: '',
hint: 'The name of the input field containing the file data to be processed',
displayOptions: {
show: {
operation: ['fromFile'],
},
},
};
export const toFileProperties: INodeProperties[] = [
{
displayName: 'File Format',
name: 'fileFormat',
type: 'options',
options: [
{
name: 'CSV',
value: 'csv',
description: 'Comma-separated values',
},
{
name: 'HTML',
value: 'html',
description: 'HTML Table',
},
{
name: 'ODS',
value: 'ods',
description: 'OpenDocument Spreadsheet',
},
{
name: 'RTF',
value: 'rtf',
description: 'Rich Text Format',
},
{
name: 'XLS',
value: 'xls',
description: 'Excel',
},
{
name: 'XLSX',
value: 'xlsx',
description: 'Excel',
},
],
default: 'xls',
displayOptions: {
show: {
operation: ['toFile'],
},
},
description: 'The format of the file to save the data as',
},
{
displayName: 'Put Output File in Field',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
required: true,
displayOptions: {
show: {
operation: ['toFile'],
},
},
placeholder: '',
hint: 'The name of the output binary field to put the file in',
},
];
export const toFileOptions: INodeProperties = {
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
operation: ['toFile'],
},
},
options: [
{
displayName: 'Compression',
name: 'compression',
type: 'boolean',
displayOptions: {
show: {
'/fileFormat': ['xlsx', 'ods'],
},
},
default: false,
description: 'Whether compression will be applied or not',
},
{
displayName: 'File Name',
name: 'fileName',
type: 'string',
default: '',
description:
'File name to set in binary data. By default will "spreadsheet.<fileFormat>" be used.',
},
{
displayName: 'Header Row',
name: 'headerRow',
type: 'boolean',
default: true,
description: 'Whether the first row of the file contains the header names',
},
{
displayName: 'Sheet Name',
name: 'sheetName',
type: 'string',
displayOptions: {
show: {
'/fileFormat': ['ods', 'xls', 'xlsx'],
},
},
default: 'Sheet',
description: 'Name of the sheet to create in the spreadsheet',
},
],
};
export const fromFileOptions: INodeProperties = {
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
operation: ['fromFile'],
},
},
options: [
{
displayName: 'Delimiter',
name: 'delimiter',
type: 'string',
displayOptions: {
show: {
'/fileFormat': ['csv'],
},
},
default: ',',
placeholder: 'e.g. ,',
description: 'Set the field delimiter, usually a comma',
},
{
displayName: 'Encoding',
name: 'encoding',
type: 'options',
displayOptions: {
show: {
'/fileFormat': ['csv'],
},
},
options: [
{ name: 'ASCII', value: 'ascii' },
{ name: 'Latin1', value: 'latin1' },
{ name: 'UCS-2', value: 'ucs-2' },
{ name: 'UCS2', value: 'ucs2' },
{ name: 'UTF-8', value: 'utf-8' },
{ name: 'UTF16LE', value: 'utf16le' },
{ name: 'UTF8', value: 'utf8' },
],
default: 'utf-8',
},
{
displayName: 'Exclude Byte Order Mark (BOM)',
name: 'enableBOM',
type: 'boolean',
displayOptions: {
show: {
'/fileFormat': ['csv'],
},
},
default: false,
description:
'Whether to detect and exclude the byte-order-mark from the CSV Input if present',
},
{
displayName: 'Preserve Quotes',
name: 'relaxQuotes',
type: 'boolean',
displayOptions: {
show: {
'/fileFormat': ['csv'],
},
},
default: false,
description:
"Whether to handle unclosed quotes in CSV fields as part of the field's content instead of throwing a parsing error",
},
{
displayName: 'Header Row',
name: 'headerRow',
type: 'boolean',
default: true,
description: 'Whether the first row of the file contains the header names',
},
{
displayName: 'Include Empty Cells',
name: 'includeEmptyCells',
type: 'boolean',
default: false,
description:
'Whether to include empty cells when reading from file. They will be filled with an empty string.',
},
{
displayName: 'Max Number of Rows to Load',
name: 'maxRowCount',
type: 'number',
displayOptions: {
show: {
'/fileFormat': ['csv'],
},
},
default: -1,
placeholder: 'e.g. 10',
description:
'Stop handling records after the requested number of rows are read. Use -1 if you want to load all rows.',
},
{
displayName: 'Range',
name: 'range',
type: 'string',
default: '',
description:
'The range to read from the table. If set to a number it will be the starting row. If set to string it will be used as A1-style notation range.',
},
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
default: false,
description: 'Whether to return RAW data, instead of parsing it',
},
{
displayName: 'Read As String',
name: 'readAsString',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'In some cases and file formats, it is necessary to read as string to ensure special characters are interpreted correctly',
},
{
displayName: 'Sheet Name',
name: 'sheetName',
type: 'string',
default: 'Sheet',
placeholder: 'e.g. mySheet',
description:
'Name of the sheet to read from in the spreadsheet (if supported). If not set, the first one will be chosen.',
},
{
displayName: 'Starting Line',
name: 'fromLine',
type: 'number',
displayOptions: {
show: {
'/fileFormat': ['csv'],
},
},
default: 0,
placeholder: 'e.g. 0',
description: 'Start handling records from the requested line number. Starts at 0.',
},
{
displayName: 'Skip Records With Errors',
name: 'skipRecordsWithErrors',
type: 'fixedCollection',
default: { value: { enabled: true, maxSkippedRecords: -1 } },
options: [
{
displayName: 'Value',
name: 'value',
values: [
{
displayName: 'Enabled',
name: 'enabled',
type: 'boolean',
default: false,
description: 'Whether to skip records with errors when reading from file',
},
{
displayName: 'Max Skipped Records',
name: 'maxSkippedRecords',
type: 'number',
default: -1,
description:
'The maximum number of records that can be skipped, will throw an error if exceeded. Set to -1 to remove limit.',
},
],
},
],
displayOptions: {
show: {
'/fileFormat': ['csv'],
},
},
},
],
};
@@ -0,0 +1,200 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import { readFileSync } from 'fs';
import type { WorkflowTestData } from 'n8n-workflow';
import path from 'path';
describe('Execute Spreadsheet File Node', () => {
const testHarness = new NodeTestHarness();
const readBinaryFile = (fileName: string) =>
readFileSync(path.resolve(__dirname, fileName), 'base64');
const loadWorkflow = (fileName: string, csvName: string) => {
const workflowData = testHarness.readWorkflowJSON(fileName);
const node = workflowData.nodes.find((n) => n.name === 'Read Binary File')!;
node.parameters.fileSelector = path.join(__dirname, csvName);
return workflowData;
};
const tests: WorkflowTestData[] = [
{
description: 'execute workflow.json',
input: {
workflowData: loadWorkflow('workflow.json', 'spreadsheet.csv'),
},
output: {
assertBinaryData: true,
nodeData: {
'Read From File': [
[
{
json: { A: 1, B: 2, C: 3 },
},
{
json: { A: 4, B: 5, C: 6 },
},
],
],
'Read From File Range': [
[
{
json: { '1': 4, '2': 5 },
},
],
],
'Read From File no Header Row': [
[
{
json: {
row: ['A', 'B', 'C'],
},
},
{
json: {
row: [1, 2, 3],
},
},
{
json: {
row: [4, 5, 6],
},
},
],
],
'Read From File Raw Data': [
[
{
json: { A: '1', B: '2', C: '3' },
},
{
json: { A: '4', B: '5', C: '6' },
},
],
],
'Read From File Read as String': [
[
{
json: { A: 1, B: 2, C: 3 },
},
{
json: { A: 4, B: 5, C: 6 },
},
],
],
'Read CSV with Row Limit': [[{ json: { A: '1', B: '2', C: '3' } }]],
'Write To File CSV': [
[
{
json: {},
binary: {
data: {
mimeType: 'text/csv',
fileType: 'text',
fileExtension: 'csv',
data: '77u/QSxCLEMKMSwyLDMKNCw1LDY=',
fileName: 'spreadsheet.csv',
fileSize: '20 B',
},
},
},
],
],
'Write To File HTML': [
[
{
json: {},
binary: {
data: {
mimeType: 'text/html',
fileType: 'html',
fileExtension: 'html',
data: readBinaryFile('spreadsheet.html'),
fileName: 'spreadsheet.html',
fileSize: '535 B',
},
},
},
],
],
// ODS file has slight differences every time it's created
//
'Write To File RTF': [
[
{
json: {},
binary: {
data: {
mimeType: 'application/rtf',
fileExtension: 'rtf',
data: readBinaryFile('spreadsheet.rtf'),
fileName: 'spreadsheet.rtf',
fileSize: '267 B',
},
},
},
],
],
'Write To File XLS': [
[
{
json: {},
binary: {
data: {
mimeType: 'application/vnd.ms-excel',
fileExtension: 'xls',
data: readBinaryFile('spreadsheet.xls'),
fileName: 'spreadsheet.xls',
fileSize: '3.58 kB',
},
},
},
],
],
},
},
},
{
description: 'execute workflow.bom.json',
input: {
workflowData: loadWorkflow('workflow.bom.json', 'bom.csv'),
},
output: {
nodeData: {
'Edit with BOM included': [[{ json: { X: null } }]],
'Edit with BOM excluded': [[{ json: { X: '1' } }]],
},
},
},
{
description: 'execute includeempty.json',
input: {
workflowData: loadWorkflow('workflow.empty.json', 'includeempty.csv'),
},
output: {
nodeData: {
'Include Empty': [[{ json: { A: '1', B: '', C: '3' } }]],
'Ignore Empty': [[{ json: { A: '1', C: '3' } }]],
},
},
},
{
description: 'execute utf8.json',
input: {
workflowData: loadWorkflow('workflow.utf8.json', 'utf8.csv'),
},
output: {
nodeData: {
'Parse UTF8 v1': [
[{ json: { A: 1, B: '株式会社', C: 3 } }, { json: { A: 4, B: 5, C: '🐛' } }],
],
'Parse UTF8 v2': [
[{ json: { A: '1', B: '株式会社', C: '3' } }, { json: { A: '4', B: '5', C: '🐛' } }],
],
},
},
},
];
for (const testData of tests) {
testHarness.setupTest(testData);
}
});
@@ -0,0 +1,2 @@
a,b,c
1,2,3
1 a b c
2 1 2 3
@@ -0,0 +1,800 @@
import { mockDeep } from 'jest-mock-extended';
import type { IBinaryData, IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow';
import { Readable } from 'stream';
jest.mock('xlsx', () => ({
read: jest.fn(),
utils: {
sheet_to_json: jest.fn(),
},
}));
import { read as xlsxRead, utils as xlsxUtils } from 'xlsx';
import { execute } from '../v2/fromFile.operation';
describe('fromFile.operation - xlsx parsing logic', () => {
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
const mockBinaryDataInMemory: IBinaryData = {
data: 'dGVzdCBkYXRh',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileExtension: 'xlsx',
fileName: 'test.xlsx',
};
const mockBinaryDataWithId: IBinaryData = {
id: 'binary-data-id-123',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileExtension: 'xlsx',
fileName: 'test.xlsx',
data: '',
};
const mockWorkbook = {
SheetNames: ['Sheet1', 'Sheet2'],
Sheets: {
Sheet1: {
A1: { t: 's', v: 'Name' },
B1: { t: 's', v: 'Age' },
C1: { t: 's', v: 'City' },
A2: { t: 's', v: 'John' },
B2: { t: 'n', v: 25 },
C2: { t: 's', v: 'NYC' },
A3: { t: 's', v: 'Jane' },
B3: { t: 'n', v: 30 },
C3: { t: 's', v: 'LA' },
},
Sheet2: {
A1: { t: 's', v: 'Product' },
B1: { t: 'n', v: 100 },
},
},
};
const mockParsedData = [
{ Name: 'John', Age: 25, City: 'NYC' },
{ Name: 'Jane', Age: 30, City: 'LA' },
];
beforeEach(() => {
jest.clearAllMocks();
mockExecuteFunctions.getNodeParameter.mockImplementation(
(paramName: string, _itemIndex: number, defaultValue?: any) => {
switch (paramName) {
case 'fileFormat':
return 'xlsx';
case 'binaryPropertyName':
return 'data';
case 'options':
return {};
default:
return defaultValue;
}
},
);
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(mockBinaryDataInMemory);
mockExecuteFunctions.getNode.mockReturnValue({
name: 'SpreadsheetFile',
type: 'n8n-nodes-base.spreadsheetFile',
id: 'test-node-id',
} as INode);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
(xlsxRead as jest.Mock).mockReturnValue(mockWorkbook);
(xlsxUtils.sheet_to_json as jest.Mock).mockReturnValue(mockParsedData);
});
describe('Basic xlsx parsing', () => {
it('should parse xlsx file from in-memory binary data', async () => {
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(result).toHaveLength(2);
expect(result[0].json).toEqual({ Name: 'John', Age: 25, City: 'NYC' });
expect(result[1].json).toEqual({ Name: 'Jane', Age: 30, City: 'LA' });
expect(result[0].pairedItem).toEqual({ item: 0 });
expect(result[1].pairedItem).toEqual({ item: 0 });
expect(xlsxRead).toHaveBeenCalledWith(
Buffer.from(mockBinaryDataInMemory.data, BINARY_ENCODING),
{ raw: undefined },
);
expect(xlsxUtils.sheet_to_json).toHaveBeenCalledWith(mockWorkbook.Sheets.Sheet1, {});
});
it('should parse xlsx file from filesystem binary data', async () => {
const mockStream = new Readable();
mockStream.push(Buffer.from('test xlsx content'));
mockStream.push(null);
const mockBuffer = Buffer.from('test xlsx content');
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(mockBinaryDataWithId);
mockExecuteFunctions.helpers.getBinaryStream.mockResolvedValue(mockStream);
mockExecuteFunctions.helpers.binaryToBuffer.mockResolvedValue(mockBuffer);
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(result).toHaveLength(2);
expect(mockExecuteFunctions.helpers.getBinaryStream).toHaveBeenCalledWith(
'binary-data-id-123',
262144,
);
expect(mockExecuteFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith(mockStream);
expect(xlsxRead).toHaveBeenCalledWith(mockBuffer, { raw: undefined });
});
});
describe('Options handling', () => {
it('should respect rawData option', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { rawData: true };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
expect(xlsxRead).toHaveBeenCalledWith(expect.any(Buffer), { raw: true });
});
it('should respect readAsString option', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { readAsString: true };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
expect(xlsxRead).toHaveBeenCalledWith(expect.any(String), { raw: undefined, type: 'binary' });
});
it('should use specified sheet name', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { sheetName: 'Sheet2' };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
expect(xlsxUtils.sheet_to_json).toHaveBeenCalledWith(mockWorkbook.Sheets.Sheet2, {});
});
it('should handle range option as string', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { range: 'A1:B2' };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
expect(xlsxUtils.sheet_to_json).toHaveBeenCalledWith(mockWorkbook.Sheets.Sheet1, {
range: 'A1:B2',
});
});
it('should handle range option as number', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { range: '2' };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
expect(xlsxUtils.sheet_to_json).toHaveBeenCalledWith(mockWorkbook.Sheets.Sheet1, {
range: 2,
});
});
it('should include empty cells when option is set', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { includeEmptyCells: true };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
expect(xlsxUtils.sheet_to_json).toHaveBeenCalledWith(mockWorkbook.Sheets.Sheet1, {
defval: '',
});
});
it('should handle headerRow=false option', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { headerRow: false };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const mockArrayData = [
['Name', 'Age'],
['John', 25],
['Jane', 30],
];
(xlsxUtils.sheet_to_json as jest.Mock).mockReturnValue(mockArrayData);
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(xlsxUtils.sheet_to_json).toHaveBeenCalledWith(mockWorkbook.Sheets.Sheet1, {
header: 1,
});
expect(result).toHaveLength(3);
expect(result[0].json).toEqual({ row: ['Name', 'Age'] });
expect(result[1].json).toEqual({ row: ['John', 25] });
expect(result[2].json).toEqual({ row: ['Jane', 30] });
});
});
describe('Error handling', () => {
it('should throw error when workbook has no sheets', async () => {
const emptyWorkbook = { SheetNames: [], Sheets: {} };
(xlsxRead as jest.Mock).mockReturnValue(emptyWorkbook);
const items: INodeExecutionData[] = [{ json: {} }];
await expect(execute.call(mockExecuteFunctions, items)).rejects.toThrow(NodeOperationError);
});
it('should throw error when specified sheet does not exist', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { sheetName: 'NonExistentSheet' };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await expect(execute.call(mockExecuteFunctions, items)).rejects.toThrow(NodeOperationError);
});
it('should handle continueOnFail gracefully', async () => {
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
(xlsxRead as jest.Mock).mockImplementation(() => {
throw new Error('Invalid file format');
});
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(result).toHaveLength(1);
expect(result[0].json.error).toContain('Invalid file format');
expect(result[0].pairedItem).toEqual({ item: 0 });
});
it('should enhance error message when file extension does not match format', async () => {
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue({
...mockBinaryDataInMemory,
fileExtension: 'pdf',
});
(xlsxRead as jest.Mock).mockImplementation(() => {
throw new Error('Parse error');
});
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(result[0].json.error).toContain('not in xlsx format');
});
});
describe('Multiple items processing', () => {
it('should process multiple items correctly', async () => {
const items: INodeExecutionData[] = [{ json: { id: 1 } }, { json: { id: 2 } }];
mockExecuteFunctions.helpers.assertBinaryData
.mockReturnValueOnce(mockBinaryDataInMemory)
.mockReturnValueOnce({
...mockBinaryDataInMemory,
fileName: 'test2.xlsx',
});
const result = await execute.call(mockExecuteFunctions, items);
expect(result).toHaveLength(4);
expect(result[0].pairedItem).toEqual({ item: 0 });
expect(result[1].pairedItem).toEqual({ item: 0 });
expect(result[2].pairedItem).toEqual({ item: 1 });
expect(result[3].pairedItem).toEqual({ item: 1 });
});
});
describe('File format detection', () => {
it('should handle autodetect for xlsx files', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'fileFormat') return 'autodetect';
if (paramName === 'binaryPropertyName') return 'data';
if (paramName === 'options') return {};
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(result).toHaveLength(2);
expect(xlsxRead).toHaveBeenCalled();
});
});
describe('Additional edge cases', () => {
it('should handle mixed file formats with autodetect', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'fileFormat') return 'autodetect';
if (paramName === 'binaryPropertyName') return 'data';
if (paramName === 'options') return {};
return undefined;
});
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue({
...mockBinaryDataInMemory,
mimeType: 'application/octet-stream',
fileExtension: 'xlsx',
});
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(result).toHaveLength(2);
expect(xlsxRead).toHaveBeenCalled();
});
it('should handle custom binary property name correctly', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'binaryPropertyName') return 'customBinaryField';
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'options') return {};
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
expect(mockExecuteFunctions.helpers.assertBinaryData).toHaveBeenCalledWith(
0,
'customBinaryField',
);
});
it('should handle binary data stream errors gracefully', async () => {
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(mockBinaryDataWithId);
mockExecuteFunctions.helpers.getBinaryStream.mockRejectedValue(new Error('Stream error'));
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(result).toHaveLength(1);
expect(result[0].json.error).toContain('Stream error');
});
});
describe('Binary string conversion', () => {
it('should convert buffer to binary string when readAsString is true', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { readAsString: true };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
expect(xlsxRead).toHaveBeenCalledWith(expect.any(String), { raw: undefined, type: 'binary' });
const callArgs = (xlsxRead as jest.Mock).mock.calls[0];
const passedData = callArgs[0];
const expectedBinaryString = Buffer.from(
mockBinaryDataInMemory.data,
BINARY_ENCODING,
).toString('binary');
expect(passedData).toBe(expectedBinaryString);
});
it('should use buffer directly when readAsString is false', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { readAsString: false };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
// Verify that xlsxRead was called with a Buffer (no type specified)
expect(xlsxRead).toHaveBeenCalledWith(expect.any(Buffer), { raw: undefined });
});
it('should handle readAsString with filesystem binary data', async () => {
const mockStream = new Readable();
mockStream.push(Buffer.from('test xlsx content'));
mockStream.push(null);
const mockBuffer = Buffer.from('test xlsx content');
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { readAsString: true };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(mockBinaryDataWithId);
mockExecuteFunctions.helpers.getBinaryStream.mockResolvedValue(mockStream);
mockExecuteFunctions.helpers.binaryToBuffer.mockResolvedValue(mockBuffer);
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(result).toHaveLength(2);
expect(mockExecuteFunctions.helpers.getBinaryStream).toHaveBeenCalledWith(
'binary-data-id-123',
262144,
);
expect(mockExecuteFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith(mockStream);
// Verify that xlsxRead was called with binary string
expect(xlsxRead).toHaveBeenCalledWith(expect.any(String), { raw: undefined, type: 'binary' });
// Verify the string is the result of buffer.toString('binary')
const callArgs = (xlsxRead as jest.Mock).mock.calls[0];
const passedData = callArgs[0];
const expectedBinaryString = mockBuffer.toString('binary');
expect(passedData).toBe(expectedBinaryString);
});
it('should combine readAsString with other options correctly', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options')
return { readAsString: true, rawData: true, sheetName: 'Sheet2' };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
// Verify that xlsxRead was called with binary string and rawData option
expect(xlsxRead).toHaveBeenCalledWith(expect.any(String), { raw: true, type: 'binary' });
// Verify that the correct sheet was used
expect(xlsxUtils.sheet_to_json).toHaveBeenCalledWith(mockWorkbook.Sheets.Sheet2, {});
});
});
describe('Special character handling', () => {
it('should handle special characters correctly when readAsString is true', async () => {
// Mock binary data that contains special characters (e.g., accented characters, emojis)
const specialCharBinaryData: IBinaryData = {
data: Buffer.from('Special chars: àáâãäåæçèéêë 🚀 ñöü', 'utf8').toString(BINARY_ENCODING),
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileExtension: 'xlsx',
fileName: 'special-chars.xlsx',
};
const mockWorkbookWithSpecialChars = {
SheetNames: ['Sheet1'],
Sheets: {
Sheet1: {
A1: { t: 's', v: 'Special chars: àáâãäåæçèéêë 🚀 ñöü' },
A2: { t: 's', v: 'Café' },
A3: { t: 's', v: 'Naïve résumé' },
},
},
};
const mockSpecialCharData = [
{ text: 'Special chars: àáâãäåæçèéêë 🚀 ñöü' },
{ text: 'Café' },
{ text: 'Naïve résumé' },
];
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { readAsString: true };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(specialCharBinaryData);
(xlsxRead as jest.Mock).mockReturnValue(mockWorkbookWithSpecialChars);
(xlsxUtils.sheet_to_json as jest.Mock).mockReturnValue(mockSpecialCharData);
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
// Verify that xlsxRead was called with binary string type for proper character handling
expect(xlsxRead).toHaveBeenCalledWith(expect.any(String), { raw: undefined, type: 'binary' });
// Verify that special characters are preserved in the output
expect(result).toHaveLength(3);
expect(result[0].json.text).toBe('Special chars: àáâãäåæçèéêë 🚀 ñöü');
expect(result[1].json.text).toBe('Café');
expect(result[2].json.text).toBe('Naïve résumé');
});
it('should demonstrate the difference between readAsString true vs false for character encoding', async () => {
// Test data with potential encoding issues
const encodingTestData: IBinaryData = {
data: Buffer.from('Encoding test: café naïve résumé', 'utf8').toString(BINARY_ENCODING),
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileExtension: 'xlsx',
fileName: 'encoding-test.xlsx',
};
const mockWorkbookEncoding = {
SheetNames: ['Sheet1'],
Sheets: {
Sheet1: {
A1: { t: 's', v: 'Encoding test: café naïve résumé' },
},
},
};
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(encodingTestData);
(xlsxRead as jest.Mock).mockReturnValue(mockWorkbookEncoding);
(xlsxUtils.sheet_to_json as jest.Mock).mockReturnValue([
{ text: 'Encoding test: café naïve résumé' },
]);
// Test with readAsString: true
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { readAsString: true };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
await execute.call(mockExecuteFunctions, items);
// Verify that when readAsString is true, we use binary type for proper character handling
expect(xlsxRead).toHaveBeenCalledWith(expect.any(String), { raw: undefined, type: 'binary' });
// Reset mocks for second test
jest.clearAllMocks();
(xlsxRead as jest.Mock).mockReturnValue(mockWorkbookEncoding);
(xlsxUtils.sheet_to_json as jest.Mock).mockReturnValue([
{ text: 'Encoding test: café naïve résumé' },
]);
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(encodingTestData);
// Test with readAsString: false (default)
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { readAsString: false };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
await execute.call(mockExecuteFunctions, items);
// Verify that when readAsString is false, we use buffer directly (no type specified)
expect(xlsxRead).toHaveBeenCalledWith(expect.any(Buffer), { raw: undefined });
});
it('should handle various international characters when readAsString is enabled', async () => {
// Test with various international characters that might cause encoding issues
const internationalChars = [
'Chinese: 你好世界',
'Japanese: こんにちは',
'Korean: 안녕하세요',
'Arabic: مرحبا',
'Russian: Привет',
'Greek: Γεια σας',
'Hebrew: שלום',
'Thai: สวัสดี',
];
const internationalBinaryData: IBinaryData = {
data: Buffer.from(internationalChars.join('\n'), 'utf8').toString(BINARY_ENCODING),
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileExtension: 'xlsx',
fileName: 'international.xlsx',
};
const mockInternationalWorkbook = {
SheetNames: ['Sheet1'],
Sheets: {
Sheet1: internationalChars.reduce((acc, char, index) => {
acc[`A${index + 1}`] = { t: 's', v: char };
return acc;
}, {} as any),
},
};
const mockInternationalData = internationalChars.map((char) => ({ text: char }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return { readAsString: true };
if (paramName === 'fileFormat') return 'xlsx';
if (paramName === 'binaryPropertyName') return 'data';
return undefined;
});
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(internationalBinaryData);
(xlsxRead as jest.Mock).mockReturnValue(mockInternationalWorkbook);
(xlsxUtils.sheet_to_json as jest.Mock).mockReturnValue(mockInternationalData);
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
// Verify that xlsxRead was called with binary string type
expect(xlsxRead).toHaveBeenCalledWith(expect.any(String), { raw: undefined, type: 'binary' });
// Verify that all international characters are preserved
expect(result).toHaveLength(8);
internationalChars.forEach((expectedChar, index) => {
expect(result[index].json.text).toBe(expectedChar);
});
});
});
describe('CSV parsing with skipRecordsWithErrors', () => {
const invalidCsvData = 'id,name\n3,"John"\n1,"Alice\n2,"Bob"';
const mockBinaryDataCSV: IBinaryData = {
data: Buffer.from(invalidCsvData, 'utf8').toString(BINARY_ENCODING),
mimeType: 'text/csv',
fileExtension: 'csv',
fileName: 'test.csv',
};
beforeEach(() => {
jest.clearAllMocks();
mockExecuteFunctions.getNodeParameter.mockImplementation(
(paramName: string, _itemIndex: number, defaultValue?: any) => {
switch (paramName) {
case 'fileFormat':
return 'csv';
case 'binaryPropertyName':
return 'data';
case 'options':
return {};
default:
return defaultValue;
}
},
);
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(mockBinaryDataCSV);
mockExecuteFunctions.getNode.mockReturnValue({
name: 'SpreadsheetFile',
type: 'n8n-nodes-base.spreadsheetFile',
id: 'test-node-id',
} as INode);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
});
it('should skip records with errors when skipRecordsWithErrors is enabled with limit -1', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'fileFormat') return 'csv';
if (paramName === 'binaryPropertyName') return 'data';
if (paramName === 'options')
return {
skipRecordsWithErrors: { value: { enabled: true, maxSkippedRecords: -1 } },
columns: true,
};
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
// Should have 1 valid record (John), Bob and Alice is considered a single record with error
expect(result).toHaveLength(1);
expect(result[0].json).toEqual({ id: '3', name: 'John' });
});
it('should skip records with errors when skipRecordsWithErrors is enabled with limit 1', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'fileFormat') return 'csv';
if (paramName === 'binaryPropertyName') return 'data';
if (paramName === 'options')
return {
skipRecordsWithErrors: { value: { enabled: true, maxSkippedRecords: 1 } },
columns: true,
};
return undefined;
});
const items: INodeExecutionData[] = [{ json: {} }];
const result = await execute.call(mockExecuteFunctions, items);
expect(result).toHaveLength(1);
expect(result[0].json).toEqual({ id: '3', name: 'John' });
expect(result[0].pairedItem).toEqual({ item: 0 });
});
it('should throw error when skipped records exceed maxSkippedRecords limit', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'fileFormat') return 'csv';
if (paramName === 'binaryPropertyName') return 'data';
if (paramName === 'options')
return {
skipRecordsWithErrors: { value: { enabled: true, maxSkippedRecords: 1 } },
columns: true,
};
return undefined;
});
const csvWithThreeErrors =
'id,name\n3,"John"\n1,"Alice\n2,"Bob"\n4,"Charlie\n5,"Eve\n6,"David';
const mockBinaryDataThreeErrors: IBinaryData = {
data: Buffer.from(csvWithThreeErrors, 'utf8').toString(BINARY_ENCODING),
mimeType: 'text/csv',
fileExtension: 'csv',
fileName: 'test-three-errors.csv',
};
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(mockBinaryDataThreeErrors);
const items: INodeExecutionData[] = [{ json: {} }];
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'fileFormat') return 'csv';
if (paramName === 'binaryPropertyName') return 'data';
if (paramName === 'options')
return {
skipRecordsWithErrors: { value: { enabled: true, maxSkippedRecords: 1 } },
columns: true,
};
return undefined;
});
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(mockBinaryDataThreeErrors);
await expect(execute.call(mockExecuteFunctions, items)).rejects.toThrow(
'Max number of skipped records exceeded',
);
});
});
});
@@ -0,0 +1,2 @@
A,B,C
1,,3
1 A B C
2 1 3
@@ -0,0 +1,3 @@
A,B,C
1,2,3
4,5,6
1 A B C
2 1 2 3
3 4 5 6
@@ -0,0 +1 @@
<html><head><meta charset="utf-8"/><title>SheetJS Table Export</title></head><body><table><tr><td data-t="s" data-v="A" id="sjs-A1">A</td><td data-t="s" data-v="B" id="sjs-B1">B</td><td data-t="s" data-v="C" id="sjs-C1">C</td></tr><tr><td data-t="n" data-v="1" id="sjs-A2">1</td><td data-t="n" data-v="2" id="sjs-B2">2</td><td data-t="n" data-v="3" id="sjs-C2">3</td></tr><tr><td data-t="n" data-v="4" id="sjs-A3">4</td><td data-t="n" data-v="5" id="sjs-B3">5</td><td data-t="n" data-v="6" id="sjs-C3">6</td></tr></table></body></html>
@@ -0,0 +1 @@
{\rtf1\ansi\trowd\trautofit1\cellx1\cellx2\cellx3\pard\intbl A\cell B\cell C\cell\pard\intbl\row\trowd\trautofit1\cellx1\cellx2\cellx3\pard\intbl 1\cell 2\cell 3\cell\pard\intbl\row\trowd\trautofit1\cellx1\cellx2\cellx3\pard\intbl 4\cell 5\cell 6\cell\pard\intbl\row}
@@ -0,0 +1,3 @@
A,B,C
1,株式会社,3
4,5,🐛
1 A B C
2 1 株式会社 3
3 4 5 🐛
@@ -0,0 +1,155 @@
{
"nodes": [
{
"parameters": {},
"id": "40bf604f-19f9-43e7-8bbb-74c36925f154",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-320,
1040
]
},
{
"parameters": {
"fileSelector": "bom.csv"
},
"id": "623ea890-8882-4273-973e-834652d823b5",
"name": "Read Binary File",
"type": "n8n-nodes-base.readBinaryFiles",
"typeVersion": 1,
"position": [
-100,
1040
]
},
{
"parameters": {
"fileFormat": "csv",
"options": {
"enableBOM": true
}
},
"id": "c8cca5fb-e119-4ca1-a597-4f051a7f64ea",
"name": "Exclude BOM",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 2,
"position": [
120,
960
]
},
{
"parameters": {
"fileFormat": "csv",
"options": {
"enableBOM": false
}
},
"id": "56ec11dc-966b-4d06-b8c0-61475b30333d",
"name": "Include BOM",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 2,
"position": [
120,
1180
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "X",
"stringValue": "={{ $json.a }}"
}
]
},
"include": "none",
"options": {}
},
"id": "6f6bccf2-d674-4774-9df9-6f6fd893bace",
"name": "Edit with BOM excluded",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [
320,
960
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "X",
"stringValue": "={{ $json.a }}"
}
]
},
"include": "none",
"options": {}
},
"id": "27ca5cde-19cb-4bf2-9ab4-7f7e77ad01bd",
"name": "Edit with BOM included",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [
320,
1180
]
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Read Binary File",
"type": "main",
"index": 0
}
]
]
},
"Exclude BOM": {
"main": [
[
{
"node": "Edit with BOM excluded",
"type": "main",
"index": 0
}
]
]
},
"Include BOM": {
"main": [
[
{
"node": "Edit with BOM included",
"type": "main",
"index": 0
}
]
]
},
"Read Binary File": {
"main": [
[
{
"node": "Exclude BOM",
"type": "main",
"index": 0
},
{
"node": "Include BOM",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,90 @@
{
"meta": {},
"nodes": [
{
"parameters": {
"fileFormat": "csv",
"options": {
"includeEmptyCells": false
}
},
"id": "8aed098d-3c0b-43c9-b7e8-c4106c88b409",
"name": "Ignore Empty",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 2,
"position": [
1160,
500
]
},
{
"parameters": {},
"id": "649db2c5-27dc-4cec-b084-8982632311e7",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
720,
360
]
},
{
"parameters": {
"fileSelector": "includeempty.csv"
},
"id": "5056b8c4-fb6e-4ca1-9bc1-33f5db4027ad",
"name": "Read Binary File",
"type": "n8n-nodes-base.readBinaryFiles",
"typeVersion": 1,
"position": [
940,
360
]
},
{
"parameters": {
"fileFormat": "csv",
"options": {
"includeEmptyCells": true
}
},
"id": "a4822e75-d638-45c8-887f-0487d5237267",
"name": "Include Empty",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 2,
"position": [
1160,
280
]
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Read Binary File",
"type": "main",
"index": 0
}
]
]
},
"Read Binary File": {
"main": [
[
{
"node": "Include Empty",
"type": "main",
"index": 0
},
{
"node": "Ignore Empty",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,271 @@
{
"nodes": [
{
"parameters": {},
"id": "087277cc-297d-4912-bd11-86626eff2d71",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
620,
640
]
},
{
"parameters": {
"options": {}
},
"id": "f55bc21c-c9a8-43af-bbc8-e4bdd30f0ce9",
"name": "Read From File",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1260,
640
]
},
{
"parameters": {
"fileSelector": "spreadsheet.csv"
},
"id": "d7620053-eb3d-43dd-b2cd-d60d9a08a9cc",
"name": "Read Binary File",
"type": "n8n-nodes-base.readBinaryFiles",
"typeVersion": 1,
"position": [
840,
640
]
},
{
"parameters": {
"operation": "toFile",
"fileFormat": "csv",
"options": {}
},
"id": "21bc49fe-1e6b-46d6-a04d-cb474d138e02",
"name": "Write To File CSV",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1580,
280
]
},
{
"parameters": {
"operation": "toFile",
"fileFormat": "html",
"options": {}
},
"id": "a4c2c717-5a9d-4fd6-8450-6bb78e233c05",
"name": "Write To File HTML",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1580,
460
]
},
{
"parameters": {
"operation": "toFile",
"fileFormat": "ods",
"options": {}
},
"id": "58e5a423-0477-44df-a505-6b8a40dbf275",
"name": "Write To File ODS",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1580,
640
]
},
{
"parameters": {
"operation": "toFile",
"fileFormat": "rtf",
"options": {}
},
"id": "3ae6e9c5-bc0a-44e4-959e-cfc572f4179f",
"name": "Write To File RTF",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1580,
820
]
},
{
"parameters": {
"operation": "toFile",
"options": {}
},
"id": "7e6db847-d24c-4094-907d-92ffec626f68",
"name": "Write To File XLS",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1580,
1020
]
},
{
"parameters": {
"options": {
"range": "A2:B3"
}
},
"id": "48934f0d-ac10-4862-ae0c-2ea591b111e3",
"name": "Read From File Range",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1060,
520
]
},
{
"parameters": {
"options": {
"headerRow": false
}
},
"id": "dea6f3f6-f2fb-472e-97b2-8a0c0a36bc4d",
"name": "Read From File no Header Row",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1060,
320
]
},
{
"parameters": {
"options": {
"rawData": true
}
},
"id": "38ed33fc-6906-4b09-8376-937ef3ca99be",
"name": "Read From File Raw Data",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1060,
740
]
},
{
"parameters": {
"options": {
"readAsString": true
}
},
"id": "ffe09dc8-9b7a-4baf-bd03-bd65bbce2590",
"name": "Read From File Read as String",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
1060,
940
]
},
{
"parameters": {
"fileFormat": "csv",
"options": {
"maxRowCount": 1
}
},
"id": "de905389-a11b-4dd8-8416-14d650804445",
"name": "Read CSV with Row Limit",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 2,
"position": [
-60,
1340
]
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Read Binary File",
"type": "main",
"index": 0
}
]
]
},
"Read From File": {
"main": [
[
{
"node": "Write To File CSV",
"type": "main",
"index": 0
},
{
"node": "Write To File HTML",
"type": "main",
"index": 0
},
{
"node": "Write To File ODS",
"type": "main",
"index": 0
},
{
"node": "Write To File RTF",
"type": "main",
"index": 0
},
{
"node": "Write To File XLS",
"type": "main",
"index": 0
}
]
]
},
"Read Binary File": {
"main": [
[
{
"node": "Read From File",
"type": "main",
"index": 0
},
{
"node": "Read From File Range",
"type": "main",
"index": 0
},
{
"node": "Read From File no Header Row",
"type": "main",
"index": 0
},
{
"node": "Read From File Raw Data",
"type": "main",
"index": 0
},
{
"node": "Read From File Read as String",
"type": "main",
"index": 0
},
{
"node": "Read CSV with Row Limit",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,91 @@
{
"meta": {
"instanceId": "78577815012af39cf16dad7a787b0898c42fb7514b8a7f99b2136862c2af502c"
},
"nodes": [
{
"parameters": {},
"id": "2130ab19-2efb-4217-b234-f8607d4122cc",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
260,
460
]
},
{
"parameters": {
"options": {
"readAsString": true
}
},
"id": "68e03042-aa27-43db-bfec-3c4fe07ce9f6",
"name": "Parse UTF8 v1",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 1,
"position": [
760,
360
]
},
{
"parameters": {
"fileFormat": "csv",
"options": {
"readAsString": true
}
},
"id": "6a8b7ee9-5d14-4b67-b7cc-afee6bcc1fa6",
"name": "Parse UTF8 v2",
"type": "n8n-nodes-base.spreadsheetFile",
"typeVersion": 2,
"position": [
760,
560
]
},
{
"parameters": {
"fileSelector": "utf8.csv"
},
"id": "623ea890-8882-4273-973e-834652d823b5",
"name": "Read Binary File",
"type": "n8n-nodes-base.readBinaryFiles",
"typeVersion": 1,
"position": [
480,
460
]
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Read Binary File",
"type": "main",
"index": 0
}
]
]
},
"Read Binary File": {
"main": [
[
{
"node": "Parse UTF8 v1",
"type": "main",
"index": 0
},
{
"node": "Parse UTF8 v2",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,268 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { BINARY_ENCODING, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import type {
JSON2SheetOpts,
ParsingOptions,
Sheet2JSONOpts,
WorkBook,
WritingOptions,
} from 'xlsx';
import {
read as xlsxRead,
readFile as xlsxReadFile,
utils as xlsxUtils,
write as xlsxWrite,
} from 'xlsx';
import { oldVersionNotice } from '@utils/descriptions';
import { flattenObject, generatePairedItemData } from '@utils/utilities';
import {
operationProperty,
binaryProperty,
toFileProperties,
fromFileOptions,
toFileOptions,
} from '../description';
export class SpreadsheetFileV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: 1,
defaults: {
name: 'Spreadsheet File',
color: '#2244FF',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [
oldVersionNotice,
operationProperty,
binaryProperty,
...toFileProperties,
fromFileOptions,
toFileOptions,
],
};
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const pairedItem = generatePairedItemData(items.length);
const operation = this.getNodeParameter('operation', 0);
const newItems: INodeExecutionData[] = [];
if (operation === 'fromFile') {
// Read data from spreadsheet file to workflow
for (let i = 0; i < items.length; i++) {
try {
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
const options = this.getNodeParameter('options', i, {});
const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName);
// Read the binary spreadsheet data
let workbook: WorkBook;
const xlsxOptions: ParsingOptions = { raw: options.rawData as boolean };
if (options.readAsString) xlsxOptions.type = 'string';
if (binaryData.id) {
const binaryPath = this.helpers.getBinaryPath(binaryData.id);
xlsxOptions.codepage = 65001; // utf8 codepage
workbook = xlsxReadFile(binaryPath, xlsxOptions);
} else {
const binaryDataBuffer = Buffer.from(binaryData.data, BINARY_ENCODING);
workbook = xlsxRead(
options.readAsString ? binaryDataBuffer.toString() : binaryDataBuffer,
xlsxOptions,
);
}
if (workbook.SheetNames.length === 0) {
throw new NodeOperationError(this.getNode(), 'Spreadsheet does not have any sheets!', {
itemIndex: i,
});
}
let sheetName = workbook.SheetNames[0];
if (options.sheetName) {
if (!workbook.SheetNames.includes(options.sheetName as string)) {
throw new NodeOperationError(
this.getNode(),
`Spreadsheet does not contain sheet called "${options.sheetName}"!`,
{ itemIndex: i },
);
}
sheetName = options.sheetName as string;
}
// Convert it to json
const sheetToJsonOptions: Sheet2JSONOpts = {};
if (options.range) {
if (isNaN(options.range as number)) {
sheetToJsonOptions.range = options.range;
} else {
sheetToJsonOptions.range = parseInt(options.range as string, 10);
}
}
if (options.includeEmptyCells) {
sheetToJsonOptions.defval = '';
}
if (options.headerRow === false) {
sheetToJsonOptions.header = 1; // Consider the first row as a data row
}
const sheetJson = xlsxUtils.sheet_to_json(workbook.Sheets[sheetName], sheetToJsonOptions);
// Check if data could be found in file
if (sheetJson.length === 0) {
continue;
}
// Add all the found data columns to the workflow data
if (options.headerRow === false) {
// Data was returned as an array - https://github.com/SheetJS/sheetjs#json
for (const rowData of sheetJson) {
newItems.push({
json: {
row: rowData,
},
pairedItem: {
item: i,
},
} as INodeExecutionData);
}
} else {
for (const rowData of sheetJson) {
newItems.push({
json: rowData,
pairedItem: {
item: i,
},
} as INodeExecutionData);
}
}
} catch (error) {
if (this.continueOnFail()) {
newItems.push({
json: {
error: error.message,
},
pairedItem: {
item: i,
},
});
continue;
}
throw error;
}
}
return [newItems];
} else if (operation === 'toFile') {
try {
// Write the workflow data to spreadsheet file
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', 0);
const fileFormat = this.getNodeParameter('fileFormat', 0) as string;
const options = this.getNodeParameter('options', 0, {});
const sheetToJsonOptions: JSON2SheetOpts = {};
if (options.headerRow === false) {
sheetToJsonOptions.skipHeader = true;
}
// Get the json data of the items and flatten it
let item: INodeExecutionData;
const itemData: IDataObject[] = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
item = items[itemIndex];
itemData.push(flattenObject(item.json));
}
const ws = xlsxUtils.json_to_sheet(itemData, sheetToJsonOptions);
const wopts: WritingOptions = {
bookSST: false,
type: 'buffer',
};
if (fileFormat === 'csv') {
wopts.bookType = 'csv';
} else if (fileFormat === 'html') {
wopts.bookType = 'html';
} else if (fileFormat === 'rtf') {
wopts.bookType = 'rtf';
} else if (fileFormat === 'ods') {
wopts.bookType = 'ods';
if (options.compression) {
wopts.compression = true;
}
} else if (fileFormat === 'xls') {
wopts.bookType = 'xls';
} else if (fileFormat === 'xlsx') {
wopts.bookType = 'xlsx';
if (options.compression) {
wopts.compression = true;
}
}
// Convert the data in the correct format
const sheetName = (options.sheetName as string) || 'Sheet';
const wb: WorkBook = {
SheetNames: [sheetName],
Sheets: {
[sheetName]: ws,
},
};
const wbout: Buffer = xlsxWrite(wb, wopts);
// Create a new item with only the binary spreadsheet data
const newItem: INodeExecutionData = {
json: {},
binary: {},
pairedItem,
};
let fileName = `spreadsheet.${fileFormat}`;
if (options.fileName !== undefined) {
fileName = options.fileName as string;
}
newItem.binary![binaryPropertyName] = await this.helpers.prepareBinaryData(wbout, fileName);
newItems.push(newItem);
} catch (error) {
if (this.continueOnFail()) {
newItems.push({
json: {
error: error.message,
},
pairedItem,
});
} else {
throw error;
}
}
} else {
if (this.continueOnFail()) {
return [[{ json: { error: `The operation "${operation}" is not supported!` } }]];
} else {
throw new NodeOperationError(
this.getNode(),
`The operation "${operation}" is not supported!`,
);
}
}
return [newItems];
}
}
@@ -0,0 +1,46 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import * as fromFile from './fromFile.operation';
import * as toFile from './toFile.operation';
import { operationProperty } from '../description';
export class SpreadsheetFileV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: 2,
defaults: {
name: 'Spreadsheet File',
color: '#2244FF',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [operationProperty, ...fromFile.description, ...toFile.description],
};
}
async execute(this: IExecuteFunctions) {
const items = this.getInputData();
const operation = this.getNodeParameter('operation', 0);
let returnData: INodeExecutionData[] = [];
if (operation === 'fromFile') {
returnData = await fromFile.execute.call(this, items);
}
if (operation === 'toFile') {
returnData = await toFile.execute.call(this, items);
}
return [returnData];
}
}
@@ -0,0 +1,288 @@
import { parse as createCSVParser, type Options as CSVOptions } from 'csv-parse';
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow';
import type { Sheet2JSONOpts, ParsingOptions } from 'xlsx';
import { read as xlsxRead, utils as xlsxUtils } from 'xlsx';
import { binaryProperty, fromFileOptions } from '../description';
interface Options {
maxRowCount?: number;
delimiter?: string;
fromLine?: number;
encoding?: BufferEncoding;
enableBOM?: boolean;
skipRecordsWithErrors?: {
value?: { enabled?: boolean; maxSkippedRecords?: number };
};
to?: number;
relaxQuotes?: boolean;
includeEmptyCells?: boolean;
rawData?: boolean;
readAsString?: boolean;
sheetName?: string;
range?: number | string;
headerRow?: boolean;
}
export const description: INodeProperties[] = [
binaryProperty,
{
displayName: 'File Format',
name: 'fileFormat',
type: 'options',
options: [
{
name: 'Autodetect',
value: 'autodetect',
},
{
name: 'CSV',
value: 'csv',
description: 'Comma-separated values',
},
{
name: 'HTML',
value: 'html',
description: 'HTML Table',
},
{
name: 'ODS',
value: 'ods',
description: 'OpenDocument Spreadsheet',
},
{
name: 'RTF',
value: 'rtf',
description: 'Rich Text Format',
},
{
name: 'XLS',
value: 'xls',
description: 'Excel',
},
{
name: 'XLSX',
value: 'xlsx',
description: 'Excel',
},
],
default: 'autodetect',
description: 'The format of the binary data to read from',
displayOptions: {
show: {
operation: ['fromFile'],
},
},
},
fromFileOptions,
];
export interface FromFileOptions {
failOnCsvBufferError?: boolean;
}
export async function execute(
this: IExecuteFunctions,
items: INodeExecutionData[],
fileFormatProperty = 'fileFormat',
{ failOnCsvBufferError = false }: FromFileOptions = {},
) {
const returnData: INodeExecutionData[] = [];
let fileExtension;
let fileFormat;
for (let i = 0; i < items.length; i++) {
try {
const options = this.getNodeParameter('options', i, {}) as Options;
fileFormat = this.getNodeParameter(fileFormatProperty, i, '');
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName);
fileExtension = binaryData.fileExtension;
let rows: unknown[] = [];
if (
fileFormat === 'autodetect' &&
(binaryData.mimeType === 'text/csv' ||
(binaryData.mimeType === 'text/plain' && binaryData.fileExtension === 'csv'))
) {
fileFormat = 'csv';
}
if (fileFormat === 'csv') {
const maxRowCount = options.maxRowCount as number;
const skipRecordsWithErrors = options.skipRecordsWithErrors?.value?.enabled;
const csvOptions: CSVOptions = {
delimiter: options.delimiter,
fromLine: options.fromLine,
encoding: options.encoding,
bom: options.enableBOM,
to: maxRowCount > -1 ? maxRowCount : undefined,
skip_records_with_error: skipRecordsWithErrors,
columns: options.headerRow !== false,
relax_quotes: options.relaxQuotes,
onRecord: (record) => {
if (!options.includeEmptyCells) {
record = Object.fromEntries(
Object.entries(record).filter(([_key, value]) => value !== ''),
);
}
rows.push(record);
},
};
const parser = createCSVParser(csvOptions);
let skippedRecords = 0;
parser.on('skip', (_err) => {
skippedRecords += 1;
});
if (binaryData.id) {
const stream = await this.helpers.getBinaryStream(binaryData.id);
await new Promise<void>((resolve, reject) => {
parser.on('error', reject);
parser.on('readable', () => {
stream.unpipe(parser);
stream.destroy();
resolve();
});
stream.pipe(parser);
});
} else {
parser.write(binaryData.data, BINARY_ENCODING);
if (failOnCsvBufferError) {
await new Promise<void>((resolve, reject) => {
parser.on('error', reject);
parser.on('readable', () => {
resolve();
});
parser.end();
});
} else {
// this ignores errors, but we keep it for backwards compatibility
parser.end();
}
}
const maxSkippedRecords = options.skipRecordsWithErrors?.value?.maxSkippedRecords ?? -1;
if (skipRecordsWithErrors && maxSkippedRecords > 0 && skippedRecords > maxSkippedRecords) {
throw new NodeOperationError(this.getNode(), 'Max number of skipped records exceeded', {
itemIndex: i,
});
}
} else {
const xlsxOptions: ParsingOptions = { raw: options.rawData as boolean };
let buffer: Buffer;
if (binaryData.id) {
const chunkSize = 256 * 1024;
const stream = await this.helpers.getBinaryStream(binaryData.id, chunkSize);
buffer = await this.helpers.binaryToBuffer(stream);
} else {
buffer = Buffer.from(binaryData.data, BINARY_ENCODING);
}
let workbook;
if (options.readAsString) {
xlsxOptions.type = 'binary';
const binaryString = buffer.toString('binary');
workbook = xlsxRead(binaryString, xlsxOptions);
} else {
workbook = xlsxRead(buffer, xlsxOptions);
}
if (workbook.SheetNames.length === 0) {
throw new NodeOperationError(this.getNode(), 'Spreadsheet does not have any sheets!', {
itemIndex: i,
});
}
let sheetName = workbook.SheetNames[0];
if (options.sheetName) {
if (!workbook.SheetNames.includes(options.sheetName as string)) {
throw new NodeOperationError(
this.getNode(),
`Spreadsheet does not contain sheet called "${options.sheetName}"!`,
{ itemIndex: i },
);
}
sheetName = options.sheetName as string;
}
// Convert it to json
const sheetToJsonOptions: Sheet2JSONOpts = {};
if (options.range) {
if (isNaN(options.range as number)) {
sheetToJsonOptions.range = options.range;
} else {
sheetToJsonOptions.range = parseInt(options.range as string, 10);
}
}
if (options.includeEmptyCells) {
sheetToJsonOptions.defval = '';
}
if (options.headerRow === false) {
sheetToJsonOptions.header = 1; // Consider the first row as a data row
}
rows = xlsxUtils.sheet_to_json(workbook.Sheets[sheetName], sheetToJsonOptions);
// Check if data could be found in file
if (rows.length === 0) {
continue;
}
}
// Add all the found data columns to the workflow data
if (options.headerRow === false) {
// Data was returned as an array - https://github.com/SheetJS/sheetjs#json
for (const rowData of rows) {
returnData.push({
json: {
row: rowData,
},
pairedItem: {
item: i,
},
} as INodeExecutionData);
}
} else {
for (const rowData of rows) {
returnData.push({
json: rowData,
pairedItem: {
item: i,
},
} as INodeExecutionData);
}
}
} catch (error) {
let errorDescription = error.description;
if (fileExtension && fileExtension !== fileFormat) {
error.message = `The file selected in 'Input Binary Field' is not in ${fileFormat} format`;
errorDescription = `Try to change the operation or select a ${fileFormat} file in 'Input Binary Field'`;
}
if (this.continueOnFail()) {
returnData.push({
json: {
error: error.message,
},
pairedItem: {
item: i,
},
});
continue;
}
throw new NodeOperationError(this.getNode(), error, {
itemIndex: i,
description: errorDescription,
});
}
}
return returnData;
}
@@ -0,0 +1,45 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import type { JsonToSpreadsheetBinaryFormat, JsonToSpreadsheetBinaryOptions } from '@utils/binary';
import { convertJsonToSpreadsheetBinary } from '@utils/binary';
import { generatePairedItemData } from '@utils/utilities';
import { toFileOptions, toFileProperties } from '../description';
export const description: INodeProperties[] = [...toFileProperties, toFileOptions];
export async function execute(this: IExecuteFunctions, items: INodeExecutionData[]) {
const returnData: INodeExecutionData[] = [];
const pairedItem = generatePairedItemData(items.length);
try {
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', 0);
const fileFormat = this.getNodeParameter('fileFormat', 0) as JsonToSpreadsheetBinaryFormat;
const options = this.getNodeParameter('options', 0, {}) as JsonToSpreadsheetBinaryOptions;
const binaryData = await convertJsonToSpreadsheetBinary.call(this, items, fileFormat, options);
const newItem: INodeExecutionData = {
json: {},
binary: {
[binaryPropertyName]: binaryData,
},
pairedItem,
};
returnData.push(newItem);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({
json: {
error: error.message,
},
pairedItem,
});
} else {
throw error;
}
}
return returnData;
}