diff --git a/packages/nodes-base/nodes/ItemLists.node.json b/packages/nodes-base/nodes/ItemLists.node.json
new file mode 100644
index 0000000000..74e66c818a
--- /dev/null
+++ b/packages/nodes-base/nodes/ItemLists.node.json
@@ -0,0 +1,35 @@
+{
+ "node": "n8n-nodes-base.itemLists",
+ "nodeVersion": "1.0",
+ "codexVersion": "1.0",
+ "details": "",
+ "categories": [
+ "Core Nodes"
+ ],
+ "resources": {
+ "primaryDocumentation": [
+ {
+ "url": "https://docs.n8n.io/nodes/n8n-nodes-base.itemLists/"
+ }
+ ],
+ "generic": []
+ },
+ "alias": [
+ "aggregate",
+ "dedupe",
+ "deduplicate",
+ "duplicates",
+ "limit",
+ "order",
+ "remove",
+ "slice",
+ "sort",
+ "split",
+ "unique"
+ ],
+ "subcategories": {
+ "Core Nodes": [
+ "Helpers"
+ ]
+ }
+}
diff --git a/packages/nodes-base/nodes/ItemLists.node.ts b/packages/nodes-base/nodes/ItemLists.node.ts
new file mode 100644
index 0000000000..5f5cb4bdaa
--- /dev/null
+++ b/packages/nodes-base/nodes/ItemLists.node.ts
@@ -0,0 +1,1136 @@
+import {
+ IExecuteFunctions,
+} from 'n8n-core';
+
+import {
+ IDataObject,
+ INode,
+ INodeExecutionData,
+ INodeType,
+ INodeTypeDescription,
+ NodeOperationError,
+} from 'n8n-workflow';
+
+import {
+ get,
+ isEqual,
+ isObject,
+ lt,
+ merge,
+ pick,
+ reduce,
+ set,
+ unset,
+} from 'lodash';
+
+const {
+ NodeVM,
+} = require('vm2');
+
+export class ItemLists implements INodeType {
+ description: INodeTypeDescription = {
+ displayName: 'Item Lists',
+ name: 'itemLists',
+ icon: 'file:itemLists.svg',
+ group: ['input'],
+ version: 1,
+ subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
+ description: 'Helper for working with lists of items and transforming arrays',
+ defaults: {
+ name: 'Item Lists',
+ color: '#ff6d5a',
+ },
+ inputs: ['main'],
+ outputs: ['main'],
+ credentials: [],
+ properties: [
+ {
+ displayName: 'Resource',
+ name: 'resource',
+ type: 'hidden',
+ options: [
+ {
+ name: 'Item List',
+ value: 'itemList',
+ },
+ ],
+ default: 'itemList',
+ },
+ {
+ displayName: 'Operation',
+ name: 'operation',
+ type: 'options',
+ options: [
+ {
+ name: 'Split Out Items',
+ value: 'splitOutItems',
+ description: 'Turn a list inside item(s) into separate items',
+ },
+ {
+ name: 'Aggregate Items',
+ value: 'aggregateItems',
+ description: 'Merge fields into a single new item',
+ },
+ {
+ name: 'Remove Duplicates',
+ value: 'removeDuplicates',
+ description: 'Remove extra items that are similar',
+ },
+ {
+ name: 'Sort',
+ value: 'sort',
+ description: 'Change the item order',
+ },
+ {
+ name: 'Limit',
+ value: 'limit',
+ description: 'Remove items if there are too many',
+ },
+ ],
+ default: 'splitOutItems',
+ },
+ // Split out items - Fields
+
+ {
+ displayName: 'Field To Split Out',
+ name: 'fieldToSplitOut',
+ type: 'string',
+ default: '',
+ required: true,
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'splitOutItems',
+ ],
+ },
+ },
+ description: 'The name of the input field to break out into separate items',
+ },
+ {
+ displayName: 'Include',
+ name: 'include',
+ type: 'options',
+ options: [
+ {
+ name: 'No Other Fields',
+ value: 'noOtherFields',
+ },
+ {
+ name: 'All Other Fields',
+ value: 'allOtherFields',
+ },
+ {
+ name: 'Selected Other Fields',
+ value: 'selectedOtherFields',
+ },
+ ],
+ default: 'noOtherFields',
+ description: 'Whether to copy any other fields into the new items',
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'splitOutItems',
+ ],
+ },
+ },
+ },
+ {
+ displayName: 'Fields To Include',
+ name: 'fieldsToInclude',
+ type: 'fixedCollection',
+ typeOptions: {
+ multipleValues: true,
+ },
+ placeholder: 'Add Field To Include',
+ default: {},
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'splitOutItems',
+ ],
+ include: [
+ 'selectedOtherFields',
+ ],
+ },
+ },
+ options: [
+ {
+ displayName: '',
+ name: 'fields',
+ values: [
+ {
+ displayName: 'Field Name',
+ name: 'fieldName',
+ type: 'string',
+ default: '',
+ description: 'A field in the input items to aggregate together',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ displayName: 'Fields To Aggregate',
+ name: 'fieldsToAggregate',
+ type: 'fixedCollection',
+ typeOptions: {
+ multipleValues: true,
+ },
+ placeholder: 'Add Field To Aggregate',
+ default: {},
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'aggregateItems',
+ ],
+ },
+ },
+ options: [
+ {
+ displayName: '',
+ name: 'fieldToAggregate',
+ values: [
+ {
+ displayName: 'Input Field Name',
+ name: 'fieldToAggregate',
+ type: 'string',
+ default: '',
+ description: 'The name of a field in the input items to aggregate together',
+ },
+ {
+ displayName: 'Rename Field',
+ name: 'renameField',
+ type: 'boolean',
+ default: false,
+ description: 'Whether to give the field a different name in the output',
+ },
+ {
+ displayName: 'Output Field Name',
+ name: 'outputFieldName',
+ displayOptions: {
+ show: {
+ renameField: [
+ true,
+ ],
+ },
+ },
+ type: 'string',
+ default: '',
+ description: 'The name of the field to put the aggregated data in. Leave blank to use the input field name',
+ },
+ ],
+ },
+ ],
+ },
+
+ // Remove duplicates - Fields
+ {
+ displayName: 'Compare',
+ name: 'compare',
+ type: 'options',
+ options: [
+ {
+ name: 'All Fields',
+ value: 'allFields',
+ },
+ {
+ name: 'All Fields Except',
+ value: 'allFieldsExcept',
+ },
+ {
+ name: 'Selected Fields',
+ value: 'selectedFields',
+ },
+ ],
+ default: 'allFields',
+ description: 'The fields of the input items to compare to see if they are the same',
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'removeDuplicates',
+ ],
+ },
+ },
+ },
+ {
+ displayName: 'Fields To Exclude',
+ name: 'fieldsToExclude',
+ type: 'fixedCollection',
+ typeOptions: {
+ multipleValues: true,
+ },
+ placeholder: 'Add Field To Exclude',
+ default: {},
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'removeDuplicates',
+ ],
+ compare: [
+ 'allFieldsExcept',
+ ],
+ },
+ },
+ options: [
+ {
+ displayName: '',
+ name: 'fields',
+ values: [
+ {
+ displayName: 'Field Name',
+ name: 'fieldName',
+ type: 'string',
+ default: '',
+ description: 'A field in the input to exclude from the comparison',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ displayName: 'Fields To Compare',
+ name: 'fieldsToCompare',
+ type: 'fixedCollection',
+ typeOptions: {
+ multipleValues: true,
+ },
+ placeholder: 'Add Field To Exclude',
+ default: {},
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'removeDuplicates',
+ ],
+ compare: [
+ 'selectedFields',
+ ],
+ },
+ },
+ options: [
+ {
+ displayName: '',
+ name: 'fields',
+ values: [
+ {
+ displayName: 'Field Name',
+ name: 'fieldName',
+ type: 'string',
+ default: '',
+ description: 'A field in the input to add to the comparison',
+ },
+ ],
+ },
+ ],
+ },
+ // Sort - Fields
+ {
+ displayName: 'Type',
+ name: 'type',
+ type: 'options',
+ options: [
+ {
+ name: 'Simple',
+ value: 'simple',
+ },
+ {
+ name: 'Random',
+ value: 'random',
+ },
+ {
+ name: 'Code',
+ value: 'code',
+ },
+ ],
+ default: 'simple',
+ description: 'The fields of the input items to compare to see if they are the same',
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'sort',
+ ],
+ },
+ },
+ },
+ {
+ displayName: 'Fields To Sort By',
+ name: 'sortFieldsUi',
+ type: 'fixedCollection',
+ typeOptions: {
+ multipleValues: true,
+ },
+ placeholder: 'Add Field To Sort By',
+ options: [
+ {
+ displayName: '',
+ name: 'sortField',
+ values: [
+ {
+ displayName: 'Field Name',
+ name: 'fieldName',
+ type: 'string',
+ required: true,
+ default: '',
+ description: 'The field to sort by',
+ },
+ {
+ displayName: 'Order',
+ name: 'order',
+ type: 'options',
+ options: [
+ {
+ name: 'Ascending',
+ value: 'ascending',
+ },
+ {
+ name: 'Descending',
+ value: 'descending',
+ },
+ ],
+ default: 'ascending',
+ description: 'The order to sort by',
+ },
+ ],
+ },
+ ],
+ default: {},
+ description: 'The fields of the input items to compare to see if they are the same',
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'sort',
+ ],
+ type: [
+ 'simple',
+ ],
+ },
+ },
+ },
+ {
+ displayName: 'Code',
+ name: 'code',
+ type: 'string',
+ typeOptions: {
+ alwaysOpenEditWindow: true,
+ editor: 'code',
+ rows: 10,
+ },
+ default: `// The two items to compare are in the variables a and b
+// Access the fields in a.json and b.json
+// Return -1 if a should go before b
+// Return 1 if b should go before a
+// Return 0 if there's no difference
+
+fieldName = 'myField';
+
+if (a.json[fieldName] < b.json[fieldName]) {
+ return -1;
+}
+if (a.json[fieldName] > b.json[fieldName]) {
+ return 1;
+}
+return 0;`,
+ description: 'Javascript code to determine the order of any two items',
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'sort',
+ ],
+ type: [
+ 'code',
+ ],
+ },
+ },
+ },
+ // Limit - Fields
+ {
+ displayName: 'Max Items',
+ name: 'maxItems',
+ type: 'number',
+ typeOptions: {
+ minValue: 1,
+ },
+ default: 1,
+ description: 'If there are more items than this number, some are removed',
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'limit',
+ ],
+ },
+ },
+ },
+ {
+ displayName: 'Keep',
+ name: 'keep',
+ type: 'options',
+ options: [
+ {
+ name: 'First Items',
+ value: 'firstItems',
+ },
+ {
+ name: 'Last Items',
+ value: 'lastItems',
+ },
+ ],
+ default: 'firstItems',
+ description: 'When removing items, whether to keep the ones at the start or the ending',
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'limit',
+ ],
+ },
+ },
+ },
+ {
+ displayName: 'Options',
+ name: 'options',
+ type: 'collection',
+ placeholder: 'Add Field',
+ default: {},
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'removeDuplicates',
+ ],
+ compare: [
+ 'allFieldsExcept',
+ 'selectedFields',
+ ],
+ },
+ },
+ options: [
+ {
+ displayName: 'Remove Other Fields',
+ name: 'removeOtherFields',
+ type: 'boolean',
+ default: false,
+ description: 'Whether to remove any fields that are not being compared. If disabled, will keep the values from the first of the duplicates',
+ },
+ {
+ displayName: 'Disable Dot Notation',
+ name: 'disableDotNotation',
+ type: 'boolean',
+ default: false,
+ description: 'Whether to disallow referencing child fields using `parent.child` in the field name',
+ },
+ ],
+ },
+ {
+ displayName: 'Options',
+ name: 'options',
+ type: 'collection',
+ placeholder: 'Add Field',
+ default: {},
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'sort',
+ ],
+ type: [
+ 'simple',
+ ],
+ },
+ },
+ options: [
+ {
+ displayName: 'Disable Dot Notation',
+ name: 'disableDotNotation',
+ type: 'boolean',
+ default: false,
+ description: 'Whether to disallow referencing child fields using `parent.child` in the field name',
+ },
+ ],
+ },
+ {
+ displayName: 'Options',
+ name: 'options',
+ type: 'collection',
+ placeholder: 'Add Field',
+ default: {},
+ displayOptions: {
+ show: {
+ resource: [
+ 'itemList',
+ ],
+ operation: [
+ 'splitOutItems',
+ 'aggregateItems',
+ ],
+ },
+ },
+ options: [
+ {
+ displayName: 'Disable Dot Notation',
+ name: 'disableDotNotation',
+ type: 'boolean',
+ displayOptions: {
+ show: {
+ '/operation': [
+ 'splitOutItems',
+ 'aggregateItems',
+ ],
+ },
+ },
+ default: false,
+ description: 'Whether to disallow referencing child fields using `parent.child` in the field name',
+ },
+ {
+ displayName: 'Destination Field Name',
+ name: 'destinationFieldName',
+ type: 'string',
+ displayOptions: {
+ show: {
+ '/operation': [
+ 'splitOutItems',
+ ],
+ },
+ },
+ default: '',
+ description: 'The field in the output under which to put the split field contents',
+ },
+ {
+ displayName: 'Merge Lists',
+ name: 'mergeLists',
+ type: 'boolean',
+ displayOptions: {
+ show: {
+ '/operation': [
+ 'aggregateItems',
+ ],
+ },
+ },
+ default: false,
+ description: 'If the field to aggregate is a list, whether to merge the output into a single flat list (rather than a list of lists)',
+ },
+ {
+ displayName: 'Keep Missing And Null Values',
+ name: 'keepMissing',
+ type: 'boolean',
+ displayOptions: {
+ show: {
+ '/operation': [
+ 'aggregateItems',
+ ],
+ },
+ },
+ default: false,
+ description: 'Whether to add a null entry to the aggregated list when there is a missing or null value',
+ },
+ ],
+ },
+ ],
+ };
+
+ async execute(this: IExecuteFunctions): Promise
{
+ const items = this.getInputData();
+ const length = (items.length as unknown) as number;
+ const returnData: INodeExecutionData[] = [];
+ const resource = this.getNodeParameter('resource', 0) as string;
+ const operation = this.getNodeParameter('operation', 0) as string;
+ if (resource === 'itemList') {
+ if (operation === 'splitOutItems') {
+
+ for (let i = 0; i < length; i++) {
+ const fieldToSplitOut = this.getNodeParameter('fieldToSplitOut', i) as string;
+ const disableDotNotation = this.getNodeParameter('options.disableDotNotation', 0, false) as boolean;
+ const destinationFieldName = this.getNodeParameter('options.destinationFieldName', i, '') as string;
+ const include = this.getNodeParameter('include', i) as string;
+
+ let arrayToSplit;
+ if (disableDotNotation === false) {
+ arrayToSplit = get(items[i].json, fieldToSplitOut);
+ } else {
+ arrayToSplit = items[i].json[fieldToSplitOut as string];
+ }
+
+ if (arrayToSplit === undefined) {
+ if (fieldToSplitOut.includes('.') && disableDotNotation === true) {
+ throw new NodeOperationError(this.getNode(), `Couldn't find the field '${fieldToSplitOut}' in the input data`, { description: `If you're trying to use a nested field, make sure you turn off 'disable dot notation' in the node options` });
+ } else {
+ throw new NodeOperationError(this.getNode(), `Couldn't find the field '${fieldToSplitOut}' in the input data`);
+ }
+ }
+
+ if (!Array.isArray(arrayToSplit)) {
+ throw new NodeOperationError(this.getNode(), `The provided field '${fieldToSplitOut}' is not an array`);
+ } else {
+
+ for (const element of arrayToSplit) {
+ let newItem = {};
+
+ if (include === 'selectedOtherFields') {
+
+ const fieldsToInclude = (this.getNodeParameter('fieldsToInclude.fields', i, []) as [{ fieldName: string }]).map(field => field.fieldName);
+
+ if (!fieldsToInclude.length) {
+ throw new NodeOperationError(this.getNode(), 'No fields specified', { description: 'Please add a field to include' });
+ }
+
+ newItem = {
+ ...fieldsToInclude.reduce((prev, field) => {
+ if (field === fieldToSplitOut) {
+ return prev;
+ }
+ let value;
+ if (disableDotNotation === false) {
+ value = get(items[i].json, field);
+ } else {
+ value = items[i].json[field as string];
+ }
+ prev = { ...prev, [field as string]: value, };
+ return prev;
+ }, {}),
+ };
+
+ } else if (include === 'allOtherFields') {
+
+ const keys = Object.keys(items[i].json);
+
+ newItem = {
+ ...keys.reduce((prev, field) => {
+ let value;
+ if (disableDotNotation === false) {
+ value = get(items[i].json, field);
+ } else {
+ value = items[i].json[field as string];
+ }
+ prev = { ...prev, [field as string]: value, };
+ return prev;
+ }, {}),
+ };
+
+ unset(newItem, fieldToSplitOut);
+ }
+
+ if (typeof element === 'object' && include === 'noOtherFields' && destinationFieldName === '') {
+ newItem = { ...newItem, ...element };
+ } else {
+ newItem = { ...newItem, [destinationFieldName as string || fieldToSplitOut as string]: element };
+ }
+
+ returnData.push({ json: newItem });
+ }
+ }
+ }
+
+ return this.prepareOutputData(returnData);
+
+ } else if (operation === 'aggregateItems') {
+
+ const disableDotNotation = this.getNodeParameter('options.disableDotNotation', 0, false) as boolean;
+ const mergeLists = this.getNodeParameter('options.mergeLists', 0, false) as boolean;
+ const fieldsToAggregate = this.getNodeParameter('fieldsToAggregate.fieldToAggregate', 0, []) as [{ fieldToAggregate: string, renameField: boolean, outputFieldName: string }];
+ const keepMissing = this.getNodeParameter('options.keepMissing', 0, false) as boolean;
+
+ if (!fieldsToAggregate.length) {
+ throw new NodeOperationError(this.getNode(), 'No fields specified', { description: 'Please add a field to aggregate' });
+ }
+ for (const { fieldToAggregate } of fieldsToAggregate) {
+ let found = false;
+ for (const item of items) {
+ if (fieldToAggregate === '') {
+ throw new NodeOperationError(this.getNode(), 'Field to aggregate is blank', { description: 'Please add a field to aggregate' });
+ }
+ if (disableDotNotation === false) {
+ if (get(item.json, fieldToAggregate) !== undefined) {
+ found = true;
+ }
+ } else if (item.json.hasOwnProperty(fieldToAggregate)) {
+ found = true;
+ }
+ }
+ if (found === false && disableDotNotation && fieldToAggregate.includes('.')) {
+ throw new NodeOperationError(this.getNode(), `Couldn't find the field '${fieldToAggregate}' in the input data`, { description: `If you're trying to use a nested field, make sure you turn off 'disable dot notation' in the node options` });
+ } else if (found === false && keepMissing === false) {
+ throw new NodeOperationError(this.getNode(), `Couldn't find the field '${fieldToAggregate}' in the input data`);
+ }
+ }
+
+ let newItem: INodeExecutionData;
+ newItem = { json: {} };
+ // tslint:disable-next-line: no-any
+ const values: { [key: string]: any } = {};
+ const outputFields: string[] = [];
+
+ for (const { fieldToAggregate, outputFieldName, renameField } of fieldsToAggregate) {
+
+ const field = (renameField) ? outputFieldName : fieldToAggregate;
+
+ if (outputFields.includes(field)) {
+ throw new NodeOperationError(this.getNode(), `The '${field}' output field is used more than once`, { description: `Please make sure each output field name is unique` });
+ } else {
+ outputFields.push(field);
+ }
+
+ const getFieldToAggregate = () => ((disableDotNotation === false && fieldToAggregate.includes('.')) ? fieldToAggregate.split('.').pop() : fieldToAggregate);
+
+ const _outputFieldName = (outputFieldName) ? (outputFieldName) : getFieldToAggregate() as string;
+
+ if (fieldToAggregate !== '') {
+ values[_outputFieldName] = [];
+ for (let i = 0; i < length; i++) {
+ if (disableDotNotation === false) {
+ let value = get(items[i].json, fieldToAggregate);
+
+ if (!keepMissing) {
+ if (Array.isArray(value)) {
+ value = value.filter(value => value !== null);
+ } else if (value === null || value === undefined) {
+ continue;
+ }
+ }
+
+ if (Array.isArray(value) && mergeLists) {
+ values[_outputFieldName].push(...value);
+ } else {
+ values[_outputFieldName].push(value);
+ }
+
+ } else {
+ let value = items[i].json[fieldToAggregate];
+
+ if (!keepMissing) {
+ if (Array.isArray(value)) {
+ value = value.filter(value => value !== null);
+ } else if (value === null || value === undefined) {
+ continue;
+ }
+ }
+
+ if (Array.isArray(value) && mergeLists) {
+ values[_outputFieldName].push(...value);
+ } else {
+ values[_outputFieldName].push(value);
+ }
+ }
+ }
+ }
+ }
+
+ for (const key of Object.keys(values)) {
+ if (disableDotNotation === false) {
+ set(newItem.json, key, values[key]);
+ } else {
+ newItem.json[key] = values[key];
+ }
+ }
+
+ returnData.push(newItem);
+
+ return this.prepareOutputData(returnData);
+
+ } else if (operation === 'removeDuplicates') {
+
+ const compare = this.getNodeParameter('compare', 0) as string;
+ const disableDotNotation = this.getNodeParameter('options.disableDotNotation', 0, false) as boolean;
+ const removeOtherFields = this.getNodeParameter('options.removeOtherFields', 0, false) as boolean;
+
+ let keys = (disableDotNotation) ? Object.keys(items[0].json) : Object.keys(flattenKeys(items[0].json));
+
+ for (const item of items) {
+ for (const key of (disableDotNotation) ? Object.keys(item.json) : Object.keys(flattenKeys(item.json))) {
+ if (!keys.includes(key)) {
+ keys.push(key);
+ }
+ }
+ }
+
+ if (compare === 'allFieldsExcept') {
+ const fieldsToExclude = (this.getNodeParameter('fieldsToExclude.fields', 0, []) as [{ fieldName: string }]).map(field => field.fieldName);
+ if (!fieldsToExclude.length) {
+ throw new NodeOperationError(this.getNode(), 'No fields specified. Please add a field to exclude from comparison');
+ }
+ if (disableDotNotation === false) {
+ keys = Object.keys(flattenKeys(items[0].json));
+ }
+ keys = keys.filter(key => !fieldsToExclude.includes(key));
+
+ } if (compare === 'selectedFields') {
+ const fieldsToCompare = (this.getNodeParameter('fieldsToCompare.fields', 0, []) as [{ fieldName: string }]).map(field => field.fieldName);
+ if (!fieldsToCompare.length) {
+ throw new NodeOperationError(this.getNode(), 'No fields specified. Please add a field to compare on');
+ }
+ if (disableDotNotation === false) {
+ keys = Object.keys(flattenKeys(items[0].json));
+ }
+ keys = fieldsToCompare.map(key => (key.trim()));
+ }
+ // This solution is O(nlogn)
+ // add original index to the items
+ const newItems = items.map((item, index) => ({ json: { ...item['json'], __INDEX: index, }, } as INodeExecutionData));
+ //sort items using the compare keys
+ newItems.sort((a, b) => {
+ let result = 0;
+
+ for (const key of keys) {
+ let equal;
+ if (disableDotNotation === false) {
+ equal = isEqual(get(a.json, key), get(b.json, key));
+ } else {
+ equal = isEqual(a.json[key], b.json[key]);
+ }
+ if (!equal) {
+ let lessThan;
+ if (disableDotNotation === false) {
+ lessThan = lt(get(a.json, key), get(b.json, key));
+ } else {
+ lessThan = lt(a.json[key], b.json[key]);
+ }
+ result = lessThan ? -1 : 1;
+ break;
+ }
+ }
+ return result;
+ });
+
+ for (const key of keys) {
+ // tslint:disable-next-line: no-any
+ let type: any = undefined;
+ for (const item of newItems) {
+ if (key === '') {
+ throw new NodeOperationError(this.getNode(), `Name of field to compare is blank`);
+ }
+ const value = ((!disableDotNotation) ? get(item.json, key) : item.json[key]);
+ if (value === undefined && disableDotNotation && key.includes('.')) {
+ throw new NodeOperationError(this.getNode(), `'${key}' field is missing from some input items`, { description: `If you're trying to use a nested field, make sure you turn off 'disable dot notation' in the node options` });
+ } else if (value === undefined) {
+ throw new NodeOperationError(this.getNode(), `'${key}' field is missing from some input items`);
+ }
+ if (type !== undefined && value !== undefined && type !== typeof value) {
+ throw new NodeOperationError(this.getNode(), `'${key}' isn't always the same type`, { description: 'The type of this field varies between items' });
+ } else {
+ type = typeof value;
+ }
+ }
+ }
+
+ // collect the original indexes of items to be removed
+ const removedIndexes: number[] = [];
+ let temp = newItems[0];
+ for (let index = 1; index < newItems.length; index++) {
+ if (compareItems(newItems[index], temp, keys, disableDotNotation, this.getNode())) {
+ removedIndexes.push(newItems[index].json.__INDEX as unknown as number);
+ } else {
+ temp = newItems[index];
+ }
+ }
+
+ let data = items.filter((_, index) => !removedIndexes.includes(index));
+
+ if (removeOtherFields) {
+ data = data.map(item => ({ json: pick(item.json, ...keys) }));
+ }
+
+ // return the filtered items
+ return this.prepareOutputData(data);
+
+ } else if (operation === 'sort') {
+
+ let newItems = [...items];
+ const type = this.getNodeParameter('type', 0) as string;
+ const disableDotNotation = this.getNodeParameter('options.disableDotNotation', 0, false) as boolean;
+
+ if (type === 'random') {
+ shuffleArray(newItems);
+ return this.prepareOutputData(newItems);
+ }
+
+ if (type === 'simple') {
+
+ const sortFieldsUi = this.getNodeParameter('sortFieldsUi', 0) as IDataObject;
+ const sortFields = sortFieldsUi.sortField as Array<{
+ fieldName: string;
+ order: 'ascending' | 'descending'
+ }>;
+
+
+ if (!sortFields || !sortFields.length) {
+ throw new NodeOperationError(this.getNode(), 'No sorting specified. Please add a field to sort by');
+ }
+
+ for (const { fieldName } of sortFields) {
+ let found = false;
+ for (const item of items) {
+ if (disableDotNotation === false) {
+ if (get(item.json, fieldName) !== undefined) {
+ found = true;
+ }
+ } else if (item.json.hasOwnProperty(fieldName)) {
+ found = true;
+ }
+ }
+ if (found === false && disableDotNotation && fieldName.includes('.')) {
+ throw new NodeOperationError(this.getNode(), `Couldn't find the field '${fieldName}' in the input data`, { description: `If you're trying to use a nested field, make sure you turn off 'disable dot notation' in the node options` });
+ } else if (found === false) {
+ throw new NodeOperationError(this.getNode(), `Couldn't find the field '${fieldName}' in the input data`);
+ }
+ }
+
+ const sortFieldsWithDirection = sortFields.map(field => ({ name: field.fieldName, dir: field.order === 'ascending' ? 1 : -1 }));
+
+ newItems.sort((a, b) => {
+ let result = 0;
+ for (const field of sortFieldsWithDirection) {
+ let equal;
+ if (disableDotNotation === false) {
+ const _a = (typeof get(a.json, field.name) === 'string') ? (get(a.json, field.name) as string).toLowerCase() : get(a.json, field.name);
+ const _b = (typeof get(b.json, field.name) === 'string') ? (get(b.json, field.name) as string).toLowerCase() : get(b.json, field.name);
+ equal = isEqual(_a, _b);
+ } else {
+ const _a = (typeof a.json[field.name as string] === 'string') ? (a.json[field.name as string] as string).toLowerCase() : a.json[field.name as string];
+ const _b = (typeof b.json[field.name as string] === 'string') ? (b.json[field.name as string] as string).toLowerCase() : b.json[field.name as string];
+ equal = isEqual(_a, _b);
+ }
+
+ if (!equal) {
+ let lessThan;
+ if (disableDotNotation === false) {
+ const _a = (typeof get(a.json, field.name) === 'string') ? (get(a.json, field.name) as string).toLowerCase() : get(a.json, field.name);
+ const _b = (typeof get(b.json, field.name) === 'string') ? (get(b.json, field.name) as string).toLowerCase() : get(b.json, field.name);
+ lessThan = lt(_a, _b);
+ } else {
+ const _a = (typeof a.json[field.name as string] === 'string') ? (a.json[field.name as string] as string).toLowerCase() : a.json[field.name as string];
+ const _b = (typeof b.json[field.name as string] === 'string') ? (b.json[field.name as string] as string).toLowerCase() : b.json[field.name as string];
+ lessThan = lt(_a, _b);
+ }
+ if (lessThan) {
+ result = -1 * field.dir;
+ } else {
+ result = 1 * field.dir;
+ }
+ break;
+ }
+ }
+ return result;
+ });
+ } else {
+ const code = this.getNodeParameter('code', 0) as string;
+ const regexCheck = /\breturn\b/g.exec(code);
+
+ if (regexCheck && regexCheck.length) {
+
+ const sandbox = {
+ newItems,
+ };
+ const mode = this.getMode();
+ const options = {
+ console: (mode === 'manual') ? 'redirect' : 'inherit',
+ sandbox,
+ };
+ const vm = new NodeVM(options);
+
+ newItems = (await vm.run(`
+ module.exports = async function() {
+ newItems.sort( (a,b) => {
+ ${code}
+ })
+ return newItems;
+ }()`, __dirname));
+
+ } else {
+ throw new NodeOperationError(this.getNode(), `Sort code doesn't return. Please add a 'return' statement to your code`);
+ }
+ }
+ return this.prepareOutputData(newItems);
+
+ } else if (operation === 'limit') {
+
+ let newItems = items;
+ const maxItems = this.getNodeParameter('maxItems', 0) as number;
+ const keep = this.getNodeParameter('keep', 0) as string;
+
+ if (maxItems > items.length) {
+ return this.prepareOutputData(newItems);
+ }
+
+ if (keep === 'firstItems') {
+ newItems = items.slice(0, maxItems);
+ } else {
+ newItems = items.slice(items.length - maxItems, items.length);
+ }
+ return this.prepareOutputData(newItems);
+
+ } else {
+ throw new NodeOperationError(this.getNode(), `Operation '${operation}' is not recognized`);
+ }
+ } else {
+ throw new NodeOperationError(this.getNode(), `Resource '${resource}' is not recognized`);
+ }
+ }
+}
+
+const compareItems = (obj: INodeExecutionData, obj2: INodeExecutionData, keys: string[], disableDotNotation: boolean, node: INode) => {
+ let result = true;
+ for (const key of keys) {
+ if (disableDotNotation === false) {
+ if (!isEqual(get(obj.json, key), get(obj2.json, key))) {
+ result = false;
+ break;
+ }
+ } else {
+ if (!isEqual(obj.json[key as string], obj2.json[key as string])) {
+ result = false;
+ break;
+ }
+ }
+ }
+ return result;
+};
+
+const flattenKeys = (obj: {}, path: string[] = []): {} => {
+ return !isObject(obj)
+ ? { [path.join('.')]: obj }
+ : reduce(obj, (cum, next, key) => merge(cum, flattenKeys(next, [...path, key])), {});
+};
+
+// tslint:disable-next-line: no-any
+const shuffleArray = (array: any[]) => {
+ for (let i = array.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [array[i], array[j]] = [array[j], array[i]];
+ }
+};
diff --git a/packages/nodes-base/nodes/itemLists.svg b/packages/nodes-base/nodes/itemLists.svg
new file mode 100644
index 0000000000..56100defce
--- /dev/null
+++ b/packages/nodes-base/nodes/itemLists.svg
@@ -0,0 +1,13 @@
+
+
+
\ No newline at end of file
diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json
index 48fb5178a1..7b98b32bcb 100644
--- a/packages/nodes-base/package.json
+++ b/packages/nodes-base/package.json
@@ -440,6 +440,7 @@
"dist/nodes/Iterable/Iterable.node.js",
"dist/nodes/Intercom/Intercom.node.js",
"dist/nodes/Interval.node.js",
+ "dist/nodes/ItemLists.node.js",
"dist/nodes/InvoiceNinja/InvoiceNinja.node.js",
"dist/nodes/InvoiceNinja/InvoiceNinjaTrigger.node.js",
"dist/nodes/Jira/Jira.node.js",