|
|
@@ -0,0 +1,163 @@
|
|
|
+/**
|
|
|
+ * @node sort-limit-dedupe
|
|
|
+ * @name Sort / Limit / Dedupe
|
|
|
+ * @category data
|
|
|
+ * @version 1.0.0
|
|
|
+ * @description Order a list, take the first N, and drop repeats by key
|
|
|
+ * @icon arrow-down-up
|
|
|
+ */
|
|
|
+
|
|
|
+const configSchema = {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ inputField: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Array Field',
|
|
|
+ description: 'Path to the array to process',
|
|
|
+ default: 'data'
|
|
|
+ },
|
|
|
+ outputField: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Output Field',
|
|
|
+ default: 'items'
|
|
|
+ },
|
|
|
+ dedupeBy: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Dedupe By',
|
|
|
+ description: 'Field whose value must be unique. Leave empty to keep every item. Applied before sorting'
|
|
|
+ },
|
|
|
+ sortBy: {
|
|
|
+ type: 'array',
|
|
|
+ title: 'Sort By',
|
|
|
+ description: 'Applied in order, so the first entry is the primary sort',
|
|
|
+ items: {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ field: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Field'
|
|
|
+ },
|
|
|
+ direction: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Direction',
|
|
|
+ enum: ['asc', 'desc'],
|
|
|
+ default: 'asc'
|
|
|
+ },
|
|
|
+ type: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Compare As',
|
|
|
+ enum: ['auto', 'string', 'number'],
|
|
|
+ default: 'auto'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ limit: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Limit',
|
|
|
+ description: 'Keep at most this many items. 0 keeps them all',
|
|
|
+ default: 0
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const inputSchema = {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ data: { type: 'any' }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const outputSchema = {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ count: { type: 'number', description: 'Items in the result' },
|
|
|
+ removedDuplicates: { type: 'number' }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+function compareValues(left, right, compareAs) {
|
|
|
+ if (compareAs === 'number' || (compareAs === 'auto' && typeof left === 'number' && typeof right === 'number')) {
|
|
|
+ const a = Number(left);
|
|
|
+ const b = Number(right);
|
|
|
+ if (isNaN(a) && isNaN(b)) return 0;
|
|
|
+ if (isNaN(a)) return 1;
|
|
|
+ if (isNaN(b)) return -1;
|
|
|
+ return a < b ? -1 : (a > b ? 1 : 0);
|
|
|
+ }
|
|
|
+ const a = left === undefined || left === null ? '' : String(left);
|
|
|
+ const b = right === undefined || right === null ? '' : String(right);
|
|
|
+ return a < b ? -1 : (a > b ? 1 : 0);
|
|
|
+}
|
|
|
+
|
|
|
+async function execute(config, input, context) {
|
|
|
+ const inputField = config.inputField;
|
|
|
+ const outputField = config.outputField || 'items';
|
|
|
+ const dedupeBy = config.dedupeBy;
|
|
|
+ const sortBy = Array.isArray(config.sortBy) ? config.sortBy : [];
|
|
|
+ const limit = Number(config.limit) || 0;
|
|
|
+
|
|
|
+ if (outputField === 'count' || outputField === 'removedDuplicates') {
|
|
|
+ throw new Error('Sort / Limit / Dedupe: outputField cannot be "' + outputField +
|
|
|
+ '", which is a reserved output name for this node. Pick another name.');
|
|
|
+ }
|
|
|
+
|
|
|
+ let items;
|
|
|
+ if (Array.isArray(inputField)) {
|
|
|
+ items = inputField;
|
|
|
+ } else {
|
|
|
+ items = smartbotic.utils.getFieldValue(input, inputField || 'data');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!Array.isArray(items)) {
|
|
|
+ throw new Error('Sort / Limit / Dedupe: the value at "' + inputField + '" is not an array');
|
|
|
+ }
|
|
|
+
|
|
|
+ let working = items.slice();
|
|
|
+ let removedDuplicates = 0;
|
|
|
+
|
|
|
+ if (dedupeBy) {
|
|
|
+ const seen = {};
|
|
|
+ const unique = [];
|
|
|
+ for (const item of working) {
|
|
|
+ const key = String(smartbotic.utils.getFieldValue(item, dedupeBy));
|
|
|
+ if (seen[key] === true) {
|
|
|
+ removedDuplicates++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ seen[key] = true;
|
|
|
+ unique.push(item);
|
|
|
+ }
|
|
|
+ working = unique;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (sortBy.length > 0) {
|
|
|
+ working.sort(function (left, right) {
|
|
|
+ for (const rule of sortBy) {
|
|
|
+ if (!rule || !rule.field) continue;
|
|
|
+ const order = compareValues(
|
|
|
+ smartbotic.utils.getFieldValue(left, rule.field),
|
|
|
+ smartbotic.utils.getFieldValue(right, rule.field),
|
|
|
+ rule.type || 'auto'
|
|
|
+ );
|
|
|
+ if (order !== 0) {
|
|
|
+ return rule.direction === 'desc' ? -order : order;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (limit > 0 && working.length > limit) {
|
|
|
+ working = working.slice(0, limit);
|
|
|
+ }
|
|
|
+
|
|
|
+ smartbotic.log.info('Sort / Limit / Dedupe: ' + items.length + ' in, ' + working.length + ' out, ' +
|
|
|
+ removedDuplicates + ' duplicates dropped');
|
|
|
+
|
|
|
+ const result = { count: working.length, removedDuplicates: removedDuplicates };
|
|
|
+ result[outputField] = working;
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+module.exports = { configSchema, inputSchema, outputSchema, execute };
|