first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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
|
||||
|
@@ -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
|
||||
|
@@ -0,0 +1,3 @@
|
||||
A,B,C
|
||||
1,2,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}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
A,B,C
|
||||
1,株式会社,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;
|
||||
}
|
||||
Reference in New Issue
Block a user