From 1a7f0a42465574f46f00e4d9d50cf71d947dc2bc Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sun, 20 Mar 2022 04:54:31 -0400 Subject: [PATCH] feat(KoBoToolbox Node): Add KoBoToolbox Regular and Trigger Node (#2765) * First version * Added hooks * Added Credentials test * Add support for downloading attachments * Slight restructure of downloaded binaries * Added Trigger node * Some linting * Reverting package-lock changes * Minor GeoJSON parsing fixes * KoboToolbox: improve GeoJSON format * Kobo: Support for get/set validation status * Remove some logs * [kobo] Fix default attachment options * Proper debug logging * Support for hook log status filter * Kobo: Review fixes * [kobo]: Add Get All Forms + lookup Form ID * [kobo] Lookup Form ID in Trigger node * [kobo] Update branded spelling * [kobo] Support pagination * :zap: fix linting issue * :zap: Improvements to #2510 * :zap: Download files using n8n helper * :zap: Improvements * :zap: Improvements * :bug: Fix filenames * :zap: Fix some issues Co-authored-by: Yann Jouanique Co-authored-by: Jan Oberhauser --- package-lock.json | 416 +++++++++--------- .../credentials/KoBoToolboxApi.credentials.ts | 26 ++ .../nodes/KoBoToolbox/FormDescription.ts | 202 +++++++++ .../nodes/KoBoToolbox/GenericFunctions.ts | 238 ++++++++++ .../nodes/KoBoToolbox/HookDescription.ts | 184 ++++++++ .../nodes/KoBoToolbox/KoBoToolbox.node.ts | 371 ++++++++++++++++ .../KoBoToolbox/KoBoToolboxTrigger.node.ts | 168 +++++++ .../nodes-base/nodes/KoBoToolbox/Options.ts | 87 ++++ .../KoBoToolbox/SubmissionDescription.ts | 304 +++++++++++++ .../nodes/KoBoToolbox/koBoToolbox.svg | 1 + packages/nodes-base/package.json | 5 +- 11 files changed, 1789 insertions(+), 213 deletions(-) create mode 100644 packages/nodes-base/credentials/KoBoToolboxApi.credentials.ts create mode 100644 packages/nodes-base/nodes/KoBoToolbox/FormDescription.ts create mode 100644 packages/nodes-base/nodes/KoBoToolbox/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/KoBoToolbox/HookDescription.ts create mode 100644 packages/nodes-base/nodes/KoBoToolbox/KoBoToolbox.node.ts create mode 100644 packages/nodes-base/nodes/KoBoToolbox/KoBoToolboxTrigger.node.ts create mode 100644 packages/nodes-base/nodes/KoBoToolbox/Options.ts create mode 100644 packages/nodes-base/nodes/KoBoToolbox/SubmissionDescription.ts create mode 100644 packages/nodes-base/nodes/KoBoToolbox/koBoToolbox.svg diff --git a/package-lock.json b/package-lock.json index 7ecbaafb92b9c..d42744d08b2cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13837,6 +13837,15 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==" }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "optional": true, + "requires": { + "color-convert": "^2.0.1" + } + }, "array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", @@ -13872,6 +13881,21 @@ } } }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "optional": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "optional": true + }, "commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -13934,6 +13958,58 @@ "worker-rpc": "^0.1.0" } }, + "fork-ts-checker-webpack-plugin-v5": { + "version": "npm:fork-ts-checker-webpack-plugin@5.2.1", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz", + "integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==", + "optional": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "optional": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "optional": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "optional": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, "glob-parent": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", @@ -13968,6 +14044,12 @@ "slash": "^2.0.0" } }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "optional": true + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -13996,6 +14078,16 @@ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "optional": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, "micromatch": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", @@ -14031,6 +14123,17 @@ } } }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "optional": true, + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -14041,6 +14144,15 @@ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==" }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "optional": true, + "requires": { + "has-flag": "^4.0.0" + } + }, "to-regex-range": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", @@ -14134,6 +14246,12 @@ "requires": { "tslib": "^1.8.1" } + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "optional": true } } }, @@ -23641,124 +23759,6 @@ } } }, - "fork-ts-checker-webpack-plugin-v5": { - "version": "npm:fork-ts-checker-webpack-plugin@5.2.1", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz", - "integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==", - "optional": true, - "requires": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "optional": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "optional": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "optional": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "optional": true - }, - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "optional": true, - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "optional": true - }, - "jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "optional": true, - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^2.0.0" - } - }, - "schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "optional": true, - "requires": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - } - }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "optional": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "optional": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "optional": true - } - } - }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -29714,6 +29714,23 @@ "@types/yargs-parser": "*" } }, + "acorn": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", + "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==" + }, + "escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "requires": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + } + }, "jest-util": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-24.9.0.tgz", @@ -29733,6 +29750,44 @@ "source-map": "^0.6.0" } }, + "jsdom": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz", + "integrity": "sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw==", + "requires": { + "abab": "^2.0.0", + "acorn": "^5.5.3", + "acorn-globals": "^4.1.0", + "array-equal": "^1.0.0", + "cssom": ">= 0.3.2 < 0.4.0", + "cssstyle": "^1.0.0", + "data-urls": "^1.0.0", + "domexception": "^1.0.1", + "escodegen": "^1.9.1", + "html-encoding-sniffer": "^1.0.2", + "left-pad": "^1.3.0", + "nwsapi": "^2.0.7", + "parse5": "4.0.0", + "pn": "^1.1.0", + "request": "^2.87.0", + "request-promise-native": "^1.0.5", + "sax": "^1.2.4", + "symbol-tree": "^3.2.2", + "tough-cookie": "^2.3.4", + "w3c-hr-time": "^1.0.1", + "webidl-conversions": "^4.0.2", + "whatwg-encoding": "^1.0.3", + "whatwg-mimetype": "^2.1.0", + "whatwg-url": "^6.4.1", + "ws": "^5.2.0", + "xml-name-validator": "^3.0.0" + } + }, + "parse5": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", + "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==" + }, "slash": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", @@ -29742,6 +29797,37 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "requires": { + "punycode": "^2.1.0" + } + }, + "webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" + }, + "whatwg-url": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", + "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "ws": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.3.tgz", + "integrity": "sha512-jZArVERrMsKUatIdnLzqvcfydI85dvd/Fp1u/VOpfdDWQ4c9qWXe+VIeAbQ5FrDwciAkr+lzofXLz3Kuf26AOA==", + "requires": { + "async-limiter": "~1.0.0" + } } } }, @@ -31837,100 +31923,6 @@ } } }, - "jsdom": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz", - "integrity": "sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw==", - "requires": { - "abab": "^2.0.0", - "acorn": "^5.5.3", - "acorn-globals": "^4.1.0", - "array-equal": "^1.0.0", - "cssom": ">= 0.3.2 < 0.4.0", - "cssstyle": "^1.0.0", - "data-urls": "^1.0.0", - "domexception": "^1.0.1", - "escodegen": "^1.9.1", - "html-encoding-sniffer": "^1.0.2", - "left-pad": "^1.3.0", - "nwsapi": "^2.0.7", - "parse5": "4.0.0", - "pn": "^1.1.0", - "request": "^2.87.0", - "request-promise-native": "^1.0.5", - "sax": "^1.2.4", - "symbol-tree": "^3.2.2", - "tough-cookie": "^2.3.4", - "w3c-hr-time": "^1.0.1", - "webidl-conversions": "^4.0.2", - "whatwg-encoding": "^1.0.3", - "whatwg-mimetype": "^2.1.0", - "whatwg-url": "^6.4.1", - "ws": "^5.2.0", - "xml-name-validator": "^3.0.0" - }, - "dependencies": { - "acorn": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", - "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==" - }, - "escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", - "requires": { - "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" - } - }, - "parse5": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", - "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==" - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true - }, - "tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", - "requires": { - "punycode": "^2.1.0" - } - }, - "webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" - }, - "whatwg-url": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", - "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", - "requires": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, - "ws": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.3.tgz", - "integrity": "sha512-jZArVERrMsKUatIdnLzqvcfydI85dvd/Fp1u/VOpfdDWQ4c9qWXe+VIeAbQ5FrDwciAkr+lzofXLz3Kuf26AOA==", - "requires": { - "async-limiter": "~1.0.0" - } - } - } - }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", diff --git a/packages/nodes-base/credentials/KoBoToolboxApi.credentials.ts b/packages/nodes-base/credentials/KoBoToolboxApi.credentials.ts new file mode 100644 index 0000000000000..9fdb6bf8c60c7 --- /dev/null +++ b/packages/nodes-base/credentials/KoBoToolboxApi.credentials.ts @@ -0,0 +1,26 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class KoBoToolboxApi implements ICredentialType { + name = 'koBoToolboxApi'; + displayName = 'KoBoToolbox API Token'; + // See https://support.kobotoolbox.org/api.html + documentationUrl = 'koBoToolbox'; + properties = [ + { + displayName: 'API root URL', + name: 'URL', + type: 'string' as NodePropertyTypes, + default: 'https://kf.kobotoolbox.org/', + }, + { + displayName: 'API Token', + name: 'token', + type: 'string' as NodePropertyTypes, + default: '', + hint: 'You can get your API token at https://[api-root]/token/?format=json (for a logged in user)', + }, + ]; +} diff --git a/packages/nodes-base/nodes/KoBoToolbox/FormDescription.ts b/packages/nodes-base/nodes/KoBoToolbox/FormDescription.ts new file mode 100644 index 0000000000000..daf0536204643 --- /dev/null +++ b/packages/nodes-base/nodes/KoBoToolbox/FormDescription.ts @@ -0,0 +1,202 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const formOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'form', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a form', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all forms', + }, + ], + default: 'get', + }, +]; + +export const formFields: INodeProperties[] = [ + + /* -------------------------------------------------------------------------- */ + /* form:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Form ID', + name: 'formId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'get', + ], + }, + }, + description: 'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg)', + }, + /* -------------------------------------------------------------------------- */ + /* form:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Whether to return all results', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + required: false, + typeOptions: { + maxValue: 3000, + }, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + default: 1000, + description: 'The number of results to return', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Sort', + name: 'sort', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: '', + placeholder: 'Add Sort', + options: [ + { + displayName: 'Sort', + name: 'value', + values: [ + { + displayName: 'Descending', + name: 'descending', + type: 'boolean', + default: true, + description: 'Sort by descending order', + }, + { + displayName: 'Order By', + name: 'ordering', + type: 'options', + required: false, + default: 'date_modified', + options: [ + { + name: 'Asset Type', + value: 'asset_type', + }, + { + name: 'Date Modified', + value: 'date_modified', + }, + { + name: 'Name', + value: 'name', + }, + { + name: 'Owner Username', + value: 'owner__username', + }, + { + name: 'Subscribers Count', + value: 'subscribers_count', + }, + ], + description: 'Field to order by', + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Filter', + name: 'filter', + type: 'string', + default: 'asset_type:survey', + required: false, + description: 'A text search query based on form data - e.g. "owner__username:meg AND name__icontains:quixotic" - see docs for more details', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/KoBoToolbox/GenericFunctions.ts b/packages/nodes-base/nodes/KoBoToolbox/GenericFunctions.ts new file mode 100644 index 0000000000000..db5fe25363a24 --- /dev/null +++ b/packages/nodes-base/nodes/KoBoToolbox/GenericFunctions.ts @@ -0,0 +1,238 @@ +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions, + IHttpRequestOptions, + INodeExecutionData, + INodePropertyOptions, + IWebhookFunctions, +} from 'n8n-workflow'; + +import * as _ from 'lodash'; + +export async function koBoToolboxApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = await this.getCredentials('koBoToolboxApi') as IDataObject; + + // Set up pagination / scrolling + const returnAll = !!option.returnAll; + if (returnAll) { + // Override manual pagination options + _.set(option, 'qs.limit', 3000); + // Don't pass this custom param to helpers.httpRequest + delete option.returnAll; + } + + const options: IHttpRequestOptions = { + url: '', + headers: { + 'Accept': 'application/json', + 'Authorization': `Token ${credentials.token}`, + }, + json: true, + }; + if (Object.keys(option)) { + Object.assign(options, option); + } + if (options.url && !/^http(s)?:/.test(options.url)) { + options.url = credentials.URL + options.url; + } + + let results = null; + let keepLooking = true; + while (keepLooking) { + const response = await this.helpers.httpRequest(options); + // Append or set results + results = response.results ? _.concat(results || [], response.results) : response; + if (returnAll && response.next) { + options.url = response.next; + continue; + } + else { + keepLooking = false; + } + } + + return results; +} + +function parseGeoPoint(geoPoint: string): null | number[] { + // Check if it looks like a "lat lon z precision" flat string e.g. "-1.931161 30.079811 0 0" (lat, lon, elevation, precision) + const coordinates = _.split(geoPoint, ' '); + if (coordinates.length >= 2 && _.every(coordinates, coord => coord && /^-?\d+(?:\.\d+)?$/.test(_.toString(coord)))) { + // NOTE: GeoJSON uses lon, lat, while most common systems use lat, lon order! + return _.concat([ + _.toNumber(coordinates[1]), + _.toNumber(coordinates[0]), + ], _.toNumber(coordinates[2]) ? _.toNumber(coordinates[2]) : []); + } + return null; +} + +export function parseStringList(value: string): string[] { + return _.split(_.toString(value), /[\s,]+/); +} + +const matchWildcard = (value: string, pattern: string): boolean => { + const regex = new RegExp(`^${_.escapeRegExp(pattern).replace('\\*', '.*')}$`); + return regex.test(value); +}; + +const formatValue = (value: any, format: string): any => { //tslint:disable-line:no-any + if (_.isString(value)) { + // Sanitize value + value = _.toString(value); + + // Parse geoPoints + const geoPoint = parseGeoPoint(value); + if (geoPoint) { + return { + type: 'Point', + coordinates: geoPoint, + }; + } + + // Check if it's a closed polygon geo-shape: -1.954117 30.085159 0 0;-1.955005 30.084622 0 0;-1.956057 30.08506 0 0;-1.956393 30.086229 0 0;-1.955853 30.087143 0 0;-1.954609 30.08725 0 0;-1.953966 30.086735 0 0;-1.953805 30.085897 0 0;-1.954117 30.085159 0 0 + const points = value.split(';'); + if (points.length >= 2 && /^[-\d\.\s;]+$/.test(value)) { + // Using the GeoJSON format as per https://geojson.org/ + const coordinates = _.compact(points.map(parseGeoPoint)); + // Only return if all values are properly parsed + if (coordinates.length === points.length) { + return { + type: _.first(points) === _.last(points) ? 'Polygon' : 'LineString', // check if shape is closed or open + coordinates, + }; + } + } + + // Parse numbers + if ('number' === format) { + return _.toNumber(value); + } + + // Split multi-select + if ('multiSelect' === format) { + return _.split(_.toString(value), ' '); + } + } + + return value; +}; + +export function formatSubmission(submission: IDataObject, selectMasks: string[] = [], numberMasks: string[] = []): IDataObject { + // Create a shallow copy of the submission + const response = {} as IDataObject; + + for (const key of Object.keys(submission)) { + let value = _.clone(submission[key]); + // Sanitize key names: split by group, trim _ + const sanitizedKey = key.split('/').map(k => _.trim(k, ' _')).join('.'); + const leafKey = sanitizedKey.split('.').pop() || ''; + let format = 'string'; + if (_.some(numberMasks, mask => matchWildcard(leafKey, mask))) { + format = 'number'; + } + if (_.some(selectMasks, mask => matchWildcard(leafKey, mask))) { + format = 'multiSelect'; + } + + value = formatValue(value, format); + + _.set(response, sanitizedKey, value); + } + + // Reformat _geolocation + if (_.isArray(response.geolocation) && response.geolocation.length === 2 && response.geolocation[0] && response.geolocation[1]) { + response.geolocation = { + type: 'Point', + coordinates: [response.geolocation[1], response.geolocation[0]], + }; + } + + return response; +} + +export async function downloadAttachments(this: IExecuteFunctions | IWebhookFunctions, submission: IDataObject, options: IDataObject): Promise { + // Initialize return object with the original submission JSON content + const binaryItem: INodeExecutionData = { + json: { + ...submission, + }, + binary: {}, + }; + + const credentials = await this.getCredentials('koBoToolboxApi') as IDataObject; + + // Look for attachment links - there can be more than one + const attachmentList = (submission['_attachments'] || submission['attachments']) as any[]; // tslint:disable-line:no-any + if (attachmentList && attachmentList.length) { + for (const [index, attachment] of attachmentList.entries()) { + // look for the question name linked to this attachment + const filename = attachment.filename; + Object.keys(submission).forEach(question => { + if (filename.endsWith('/' + _.toString(submission[question]).replace(/\s/g, '_'))) { + } + }); + + // Download attachment + // NOTE: this needs to follow redirects (possibly across domains), while keeping Authorization headers + // The Axios client will not propagate the Authorization header on redirects (see https://github.com/axios/axios/issues/3607), so we need to follow ourselves... + let response = null; + const attachmentUrl = attachment[options.version as string] || attachment.download_url as string; + let final = false, redir = 0; + + const axiosOptions: IHttpRequestOptions = { + url: attachmentUrl, + method: 'GET', + headers: { + 'Authorization': `Token ${credentials.token}`, + }, + ignoreHttpStatusErrors: true, + returnFullResponse: true, + disableFollowRedirect: true, + encoding: 'arraybuffer', + }; + + while (!final && redir < 5) { + response = await this.helpers.httpRequest(axiosOptions); + + if (response && response.headers.location) { + // Follow redirect + axiosOptions.url = response.headers.location; + redir++; + } else { + final = true; + } + } + + const dataPropertyAttachmentsPrefixName = options.dataPropertyAttachmentsPrefixName || 'attachment_'; + const fileName = filename.split('/').pop(); + + if (response && response.body) { + binaryItem.binary![`${dataPropertyAttachmentsPrefixName}${index}`] = await this.helpers.prepareBinaryData(response.body, fileName); + } + } + } else { + delete binaryItem.binary; + } + + // Add item to final output - even if there's no attachment retrieved + return binaryItem; +} + +export async function loadForms(this: ILoadOptionsFunctions): Promise { + const responseData = await koBoToolboxApiRequest.call(this, { + url: '/api/v2/assets/', + qs: { + q: 'asset_type:survey', + ordering: 'name', + }, + scroll: true, + }); + + return responseData?.map((survey: any) => ({ name: survey.name, value: survey.uid })) || []; // tslint:disable-line:no-any +} diff --git a/packages/nodes-base/nodes/KoBoToolbox/HookDescription.ts b/packages/nodes-base/nodes/KoBoToolbox/HookDescription.ts new file mode 100644 index 0000000000000..f6416cd43b1db --- /dev/null +++ b/packages/nodes-base/nodes/KoBoToolbox/HookDescription.ts @@ -0,0 +1,184 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const hookOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'hook', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a single hook definition', + }, + { + name: 'Get All', + value: 'getAll', + description: 'List all hooks on a form', + }, + { + name: 'Logs', + value: 'getLogs', + description: 'Get hook logs', + }, + { + name: 'Retry All', + value: 'retryAll', + description: 'Retry all failed attempts for a given hook', + }, + { + name: 'Retry One', + value: 'retryOne', + description: 'Retry a specific hook', + }, + ], + default: 'getAll', + }, +]; + +export const hookFields: INodeProperties[] = [ + + /* -------------------------------------------------------------------------- */ + /* hook:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Form ID', + name: 'formId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'loadForms', + }, + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'hook', + ], + operation: [ + 'get', + 'retryOne', + 'retryAll', + 'getLogs', + ], + }, + }, + description: 'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg)', + }, + { + displayName: 'Hook ID', + name: 'hookId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'hook', + ], + operation: [ + 'get', + 'retryOne', + 'retryAll', + 'getLogs', + ], + }, + }, + default: '', + description: 'Hook ID (starts with h, e.g. hVehywQ2oXPYGHJHKtqth4)', + }, + /* -------------------------------------------------------------------------- */ + /* hook:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Form ID', + name: 'formId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'loadForms', + }, + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'hook', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg)', + }, + { + displayName: 'Hook Log ID', + name: 'logId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'hook', + ], + operation: [ + 'retryOne', + ], + }, + }, + default: '', + description: 'Hook log ID (starts with hl, e.g. hlSbGKaUKzTVNoWEVMYbLHe)', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + resource: [ + 'hook', + ], + operation: [ + 'getAll', + 'getLogs', + ], + }, + }, + description: 'Whether to return all results', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + required: false, + typeOptions: { + maxValue: 3000, + }, + displayOptions: { + show: { + resource: [ + 'hook', + ], + operation: [ + 'getAll', + 'getLogs', + ], + returnAll: [ + false, + ], + }, + }, + default: 1000, + description: 'The number of results to return', + }, +]; diff --git a/packages/nodes-base/nodes/KoBoToolbox/KoBoToolbox.node.ts b/packages/nodes-base/nodes/KoBoToolbox/KoBoToolbox.node.ts new file mode 100644 index 0000000000000..0410da5ca15ec --- /dev/null +++ b/packages/nodes-base/nodes/KoBoToolbox/KoBoToolbox.node.ts @@ -0,0 +1,371 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + INodeCredentialTestResult, + INodeExecutionData, + INodeType, + INodeTypeDescription, + JsonObject, +} from 'n8n-workflow'; + +import { + downloadAttachments, + formatSubmission, + koBoToolboxApiRequest, + loadForms, + parseStringList, +} from './GenericFunctions'; + +import { + formFields, + formOperations +} from './FormDescription'; + +import { + submissionFields, + submissionOperations, +} from './SubmissionDescription'; + +import { + hookFields, + hookOperations, +} from './HookDescription'; + +export class KoBoToolbox implements INodeType { + description: INodeTypeDescription = { + displayName: 'KoBoToolbox', + name: 'koBoToolbox', + icon: 'file:koBoToolbox.svg', + group: ['transform'], + version: 1, + description: 'Work with KoBoToolbox forms and submissions', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + defaults: { + name: 'KoBoToolbox', + color: '#64C0FF', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'koBoToolboxApi', + required: true, + testedBy: 'koBoToolboxApiCredentialTest', + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Form', + value: 'form', + }, + { + name: 'Hook', + value: 'hook', + }, + { + name: 'Submission', + value: 'submission', + }, + ], + default: 'submission', + required: true, + }, + ...formOperations, + ...formFields, + ...hookOperations, + ...hookFields, + ...submissionOperations, + ...submissionFields, + ], + }; + + methods = { + credentialTest: { + async koBoToolboxApiCredentialTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise { + const credentials = credential.data; + try { + const response = await this.helpers.request({ + url: `${credentials!.URL}/api/v2/assets/hash`, + headers: { + 'Accept': 'application/json', + 'Authorization': `Token ${credentials!.token}`, + }, + json: true, + }); + + if (response.hash) { + return { + status: 'OK', + message: 'Connection successful!', + }; + } + else { + return { + status: 'Error', + message: `Credentials are not valid. Response: ${response.detail}`, + }; + } + } + catch (err) { + return { + status: 'Error', + message: `Credentials validation failed: ${(err as JsonObject).message}`, + }; + } + }, + }, + + loadOptions: { + loadForms, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + // tslint:disable-next-line:no-any + let responseData: any; + // tslint:disable-next-line:no-any + let returnData: any[] = []; + const binaryItems: INodeExecutionData[] = []; + const items = this.getInputData(); + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < items.length; i++) { + + if (resource === 'form') { + // ********************************************************************* + // Form + // ********************************************************************* + + if (operation === 'get') { + // ---------------------------------- + // Form: get + // ---------------------------------- + const formId = this.getNodeParameter('formId', i) as string; + responseData = [await koBoToolboxApiRequest.call(this, { + url: `/api/v2/assets/${formId}`, + })]; + } + + if (operation === 'getAll') { + // ---------------------------------- + // Form: getAll + // ---------------------------------- + const formQueryOptions = this.getNodeParameter('options', i) as { + sort: { + value: { + descending: boolean, + ordering: string, + } + } + }; + const formFilterOptions = this.getNodeParameter('filters', i) as IDataObject; + + responseData = await koBoToolboxApiRequest.call(this, { + url: '/api/v2/assets/', + qs: { + limit: this.getNodeParameter('limit', i, 1000) as number, + ...(formFilterOptions.filter && { q: formFilterOptions.filter }), + ...(formQueryOptions?.sort?.value?.ordering && { ordering: (formQueryOptions?.sort?.value?.descending ? '-' : '') + formQueryOptions?.sort?.value?.ordering }), + }, + scroll: this.getNodeParameter('returnAll', i) as boolean, + }); + } + } + if (resource === 'submission') { + // ********************************************************************* + // Submissions + // ********************************************************************* + const formId = this.getNodeParameter('formId', i) as string; + + if (operation === 'getAll') { + // ---------------------------------- + // Submissions: getAll + // ---------------------------------- + + const submissionQueryOptions = this.getNodeParameter('options', i) as IDataObject; + + responseData = await koBoToolboxApiRequest.call(this, { + url: `/api/v2/assets/${formId}/data/`, + qs: { + limit: this.getNodeParameter('limit', i, 1000) as number, + ...(submissionQueryOptions.query && { query: submissionQueryOptions.query }), + //...(submissionQueryOptions.sort && { sort: submissionQueryOptions.sort }), + ...(submissionQueryOptions.fields && { fields: JSON.stringify(parseStringList(submissionQueryOptions.fields as string)) }), + }, + scroll: this.getNodeParameter('returnAll', i) as boolean, + }); + + if (submissionQueryOptions.reformat) { + responseData = responseData.map((submission: IDataObject) => { + return formatSubmission(submission, parseStringList(submissionQueryOptions.selectMask as string), parseStringList(submissionQueryOptions.numberMask as string)); + }); + } + + if (submissionQueryOptions.download) { + // Download related attachments + for (const submission of responseData) { + binaryItems.push(await downloadAttachments.call(this, submission, submissionQueryOptions)); + } + } + } + + if (operation === 'get') { + // ---------------------------------- + // Submissions: get + // ---------------------------------- + const submissionId = this.getNodeParameter('submissionId', i) as string; + const options = this.getNodeParameter('options', i) as IDataObject; + + responseData = [await koBoToolboxApiRequest.call(this, { + url: `/api/v2/assets/${formId}/data/${submissionId}`, + qs: { + ...(options.fields && { fields: JSON.stringify(parseStringList(options.fields as string)) }), + }, + })]; + + if (options.reformat) { + responseData = responseData.map((submission: IDataObject) => { + return formatSubmission(submission, parseStringList(options.selectMask as string), parseStringList(options.numberMask as string)); + }); + } + + if (options.download) { + // Download related attachments + for (const submission of responseData) { + binaryItems.push(await downloadAttachments.call(this, submission, options)); + } + } + } + + if (operation === 'delete') { + // ---------------------------------- + // Submissions: delete + // ---------------------------------- + const id = this.getNodeParameter('submissionId', i) as string; + + await koBoToolboxApiRequest.call(this, { + method: 'DELETE', + url: `/api/v2/assets/${formId}/data/${id}`, + }); + + responseData = [{ + success: true, + }]; + } + + if (operation === 'getValidation') { + // ---------------------------------- + // Submissions: getValidation + // ---------------------------------- + const submissionId = this.getNodeParameter('submissionId', i) as string; + + responseData = [await koBoToolboxApiRequest.call(this, { + url: `/api/v2/assets/${formId}/data/${submissionId}/validation_status/`, + })]; + } + + if (operation === 'setValidation') { + // ---------------------------------- + // Submissions: setValidation + // ---------------------------------- + const submissionId = this.getNodeParameter('submissionId', i) as string; + const status = this.getNodeParameter('validationStatus', i) as string; + + responseData = [await koBoToolboxApiRequest.call(this, { + method: 'PATCH', + url: `/api/v2/assets/${formId}/data/${submissionId}/validation_status/`, + body: { + 'validation_status.uid': status, + }, + })]; + } + } + + if (resource === 'hook') { + const formId = this.getNodeParameter('formId', i) as string; + // ********************************************************************* + // Hook + // ********************************************************************* + + if (operation === 'getAll') { + // ---------------------------------- + // Hook: getAll + // ---------------------------------- + responseData = await koBoToolboxApiRequest.call(this, { + url: `/api/v2/assets/${formId}/hooks/`, + qs: { + limit: this.getNodeParameter('limit', i, 1000) as number, + }, + scroll: this.getNodeParameter('returnAll', i) as boolean, + }); + } + + if (operation === 'get') { + // ---------------------------------- + // Hook: get + // ---------------------------------- + const hookId = this.getNodeParameter('hookId', i) as string; + responseData = [await koBoToolboxApiRequest.call(this, { + url: `/api/v2/assets/${formId}/hooks/${hookId}`, + })]; + } + + if (operation === 'retryAll') { + // ---------------------------------- + // Hook: retryAll + // ---------------------------------- + const hookId = this.getNodeParameter('hookId', i) as string; + responseData = [await koBoToolboxApiRequest.call(this, { + method: 'PATCH', + url: `/api/v2/assets/${formId}/hooks/${hookId}/retry/`, + })]; + } + + if (operation === 'getLogs') { + // ---------------------------------- + // Hook: getLogs + // ---------------------------------- + const hookId = this.getNodeParameter('hookId', i) as string; + responseData = await koBoToolboxApiRequest.call(this, { + url: `/api/v2/assets/${formId}/hooks/${hookId}/logs/`, + qs: { + start: this.getNodeParameter('start', i, 0) as number, + limit: this.getNodeParameter('limit', i, 1000) as number, + }, + scroll: this.getNodeParameter('returnAll', i) as boolean, + }); + } + + if (operation === 'retryOne') { + // ---------------------------------- + // Hook: retryOne + // ---------------------------------- + const hookId = this.getNodeParameter('hookId', i) as string; + const logId = this.getNodeParameter('logId', i) as string; + + responseData = [await koBoToolboxApiRequest.call(this, { + url: `/api/v2/assets/${formId}/hooks/${hookId}/logs/${logId}/retry/`, + })]; + } + } + + returnData = returnData.concat(responseData); + } + + // Map data to n8n data + return binaryItems.length > 0 + ? [binaryItems] + : [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/KoBoToolbox/KoBoToolboxTrigger.node.ts b/packages/nodes-base/nodes/KoBoToolbox/KoBoToolboxTrigger.node.ts new file mode 100644 index 0000000000000..ed97f2ce3d18b --- /dev/null +++ b/packages/nodes-base/nodes/KoBoToolbox/KoBoToolboxTrigger.node.ts @@ -0,0 +1,168 @@ +import { + IDataObject, + IHookFunctions, + INodeType, + INodeTypeDescription, + IWebhookFunctions, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + downloadAttachments, + formatSubmission, + koBoToolboxApiRequest, + loadForms, + parseStringList +} from './GenericFunctions'; + +import { + options, +} from './Options'; + +export class KoBoToolboxTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'KoBoToolbox Trigger', + name: 'koBoToolboxTrigger', + icon: 'file:koBoToolbox.svg', + group: ['trigger'], + version: 1, + description: 'Process KoBoToolbox submissions', + defaults: { + name: 'KoBoToolbox Trigger', + color: '#64C0FF', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'koBoToolboxApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Form Name/ID', + name: 'formId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'loadForms', + }, + required: true, + default: '', + description: 'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg)', + }, + { + displayName: 'Trigger On', + name: 'triggerOn', + type: 'options', + required: true, + default: 'formSubmission', + options: [ + { + name: 'On Form Submission', + value: 'formSubmission', + }, + ], + }, + { ...options }, + ], + }; + + // @ts-ignore + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const formId = this.getNodeParameter('formId') as string; //tslint:disable-line:variable-name + const webhooks = await koBoToolboxApiRequest.call(this, { + url: `/api/v2/assets/${formId}/hooks/`, + }); + for (const webhook of webhooks || []) { + if (webhook.endpoint === webhookUrl && webhook.active === true) { + webhookData.webhookId = webhook.uid; + return true; + } + } + return false; + }, + + async create(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const formId = this.getNodeParameter('formId') as string; //tslint:disable-line:variable-name + + const response = await koBoToolboxApiRequest.call(this, { + method: 'POST', + url: `/api/v2/assets/${formId}/hooks/`, + body: { + name: `n8n-webhook:${webhookUrl}`, + endpoint: webhookUrl, + email_notification: true, + }, + }); + + if (response.uid) { + webhookData.webhookId = response.uid; + return true; + } + + return false; + }, + + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const formId = this.getNodeParameter('formId') as string; //tslint:disable-line:variable-name + try { + await koBoToolboxApiRequest.call(this, { + method: 'DELETE', + url: `/api/v2/assets/${formId}/hooks/${webhookData.webhookId}`, + }); + } catch (error) { + return false; + } + delete webhookData.webhookId; + return true; + }, + }, + }; + + methods = { + loadOptions: { + loadForms, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const req = this.getRequestObject(); + const formatOptions = this.getNodeParameter('formatOptions') as IDataObject; + + const responseData = formatOptions.reformat + ? formatSubmission(req.body, parseStringList(formatOptions.selectMask as string), parseStringList(formatOptions.numberMask as string)) + : req.body; + + if (formatOptions.download) { + // Download related attachments + return { + workflowData: [ + [await downloadAttachments.call(this, responseData, formatOptions)], + ], + }; + } + else { + return { + workflowData: [ + this.helpers.returnJsonArray([responseData]), + ], + }; + } + } +} diff --git a/packages/nodes-base/nodes/KoBoToolbox/Options.ts b/packages/nodes-base/nodes/KoBoToolbox/Options.ts new file mode 100644 index 0000000000000..2824225e57ddf --- /dev/null +++ b/packages/nodes-base/nodes/KoBoToolbox/Options.ts @@ -0,0 +1,87 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const options = { + displayName: 'Options', + placeholder: 'Add Option', + name: 'formatOptions', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Attachments Prefix', + name: 'dataPropertyAttachmentsPrefixName', + type: 'string', + displayOptions: { + show: { + download: [ + true, + ], + }, + }, + default: 'attachment_', + description: 'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"', + }, + { + displayName: 'Download Attachments', + name: 'download', + type: 'boolean', + default: false, + description: 'Download submitted attachments', + }, + { + displayName: 'File Size', + name: 'version', + type: 'options', + displayOptions: { + show: { + download: [ + true, + ], + }, + }, + default: 'download_url', + description: 'Attachment size to retrieve, if multiple versions are available', + options: [ + { + name: 'Original', + value: 'download_url', + }, + { + name: 'Small', + value: 'download_small_url', + }, + { + name: 'Medium', + value: 'download_medium_url', + }, + { + name: 'Large', + value: 'download_large_url', + }, + ], + }, + { + displayName: 'Multiselect Mask', + name: 'selectMask', + type: 'string', + default: 'select_*', + description: 'Comma-separated list of wildcard-style selectors for fields that should be treated as multiselect fields, i.e. parsed as arrays', + }, + { + displayName: 'Number Mask', + name: 'numberMask', + type: 'string', + default: 'n_*, f_*', + description: 'Comma-separated list of wildcard-style selectors for fields that should be treated as numbers', + }, + { + displayName: 'Reformat', + name: 'reformat', + type: 'boolean', + default: false, + description: 'Apply some reformatting to the submission data, such as parsing GeoJSON coordinates', + }, + ], +} as INodeProperties; diff --git a/packages/nodes-base/nodes/KoBoToolbox/SubmissionDescription.ts b/packages/nodes-base/nodes/KoBoToolbox/SubmissionDescription.ts new file mode 100644 index 0000000000000..8caaa3e7b29b4 --- /dev/null +++ b/packages/nodes-base/nodes/KoBoToolbox/SubmissionDescription.ts @@ -0,0 +1,304 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const submissionOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'submission', + ], + }, + }, + options: [ + { + name: 'Delete', + value: 'delete', + description: 'Delete a single submission', + }, + { + name: 'Get', + value: 'get', + description: 'Get a single submission', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all submissions', + }, + { + name: 'Get Validation Status', + value: 'getValidation', + description: 'Get the validation status for the submission', + }, + { + name: 'Update Validation Status', + value: 'setValidation', + description: 'Set the validation status of the submission', + }, + ], + default: 'getAll', + }, +]; + +export const submissionFields: INodeProperties[] = [ + + /* -------------------------------------------------------------------------- */ + /* submission:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Form ID', + name: 'formId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'loadForms', + }, + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'submission', + ], + operation: [ + 'get', + 'delete', + 'getValidation', + 'setValidation', + ], + }, + }, + description: 'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg)', + }, + { + displayName: 'Submission ID', + name: 'submissionId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'submission', + ], + operation: [ + 'get', + 'delete', + 'getValidation', + 'setValidation', + ], + }, + }, + description: 'Submission ID (number, e.g. 245128)', + }, + { + displayName: 'Validation Status', + name: 'validationStatus', + type: 'options', + required: true, + displayOptions: { + show: { + resource: [ + 'submission', + ], + operation: [ + 'setValidation', + ], + }, + }, + default: '', + options: [ + { + name: 'Approved', + value: 'validation_status_approved', + }, + { + name: 'Not Approved', + value: 'validation_status_not_approved', + }, + { + name: 'On Hold', + value: 'validation_status_on_hold', + }, + ], + description: 'Desired Validation Status', + }, + /* -------------------------------------------------------------------------- */ + /* submission:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Form Name/ID', + name: 'formId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'loadForms', + }, + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'submission', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Form ID (e.g. aSAvYreNzVEkrWg5Gdcvg)', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + resource: [ + 'submission', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Whether to return all results', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + required: false, + typeOptions: { + maxValue: 3000, + }, + displayOptions: { + show: { + resource: [ + 'submission', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + default: 100, + description: 'The number of results to return', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + resource: [ + 'submission', + ], + operation: [ + 'get', + 'getAll', + ], + }, + }, + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'Attachments Prefix', + name: 'dataPropertyAttachmentsPrefixName', + type: 'string', + displayOptions: { + show: { + download: [ + true, + ], + }, + }, + default: 'attachment_', + description: 'Prefix for name of the binary property to which to write the attachments. An index starting with 0 will be added. So if name is "attachment_" the first attachment is saved to "attachment_0"', + }, + { + displayName: 'Download Attachments', + name: 'download', + type: 'boolean', + default: false, + description: 'Download submitted attachments', + }, + { + displayName: 'Fields to Retrieve', + name: 'fields', + type: 'string', + default: '', + description: 'Comma-separated list of fields to retrieve (e.g. _submission_time,_submitted_by). If left blank, all fields are retrieved', + }, + { + displayName: 'File Size', + name: 'version', + type: 'options', + displayOptions: { + show: { + download: [ + true, + ], + }, + }, + default: 'download_url', + description: 'Attachment size to retrieve, if multiple versions are available', + options: [ + { + name: 'Original', + value: 'download_url', + }, + { + name: 'Small', + value: 'download_small_url', + }, + { + name: 'Medium', + value: 'download_medium_url', + }, + { + name: 'Large', + value: 'download_large_url', + }, + ], + }, + { + displayName: 'Multiselect Mask', + name: 'selectMask', + type: 'string', + default: 'select_*', + description: 'Comma-separated list of wildcard-style selectors for fields that should be treated as multiselect fields, i.e. parsed as arrays', + }, + { + displayName: 'Number Mask', + name: 'numberMask', + type: 'string', + default: 'n_*, f_*', + description: 'Comma-separated list of wildcard-style selectors for fields that should be treated as numbers', + }, + { + displayName: 'Reformat', + name: 'reformat', + type: 'boolean', + default: false, + description: 'Apply some reformatting to the submission data, such as parsing GeoJSON coordinates', + }, + // { + // displayName: 'Sort', + // name: 'sort', + // type: 'json', + // default: '', + // description: 'Sort predicates, in Mongo JSON format (e.g. {"_submission_time":1})', + // }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/KoBoToolbox/koBoToolbox.svg b/packages/nodes-base/nodes/KoBoToolbox/koBoToolbox.svg new file mode 100644 index 0000000000000..a26f0eb303a81 --- /dev/null +++ b/packages/nodes-base/nodes/KoBoToolbox/koBoToolbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index be20e4d888140..84f98314c9a1f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -159,6 +159,7 @@ "dist/credentials/Kafka.credentials.js", "dist/credentials/KeapOAuth2Api.credentials.js", "dist/credentials/KitemakerApi.credentials.js", + "dist/credentials/KoBoToolboxApi.credentials.js", "dist/credentials/LemlistApi.credentials.js", "dist/credentials/LinearApi.credentials.js", "dist/credentials/LineNotifyOAuth2Api.credentials.js", @@ -493,6 +494,8 @@ "dist/nodes/Keap/Keap.node.js", "dist/nodes/Keap/KeapTrigger.node.js", "dist/nodes/Kitemaker/Kitemaker.node.js", + "dist/nodes/KoBoToolbox/KoBoToolbox.node.js", + "dist/nodes/KoBoToolbox/KoBoToolboxTrigger.node.js", "dist/nodes/Lemlist/Lemlist.node.js", "dist/nodes/Lemlist/LemlistTrigger.node.js", "dist/nodes/Line/Line.node.js", @@ -787,4 +790,4 @@ "json" ] } -} +} \ No newline at end of file