Merge branch 'master' into feature/wordpress-node

This commit is contained in:
Ricardo Espinoza
2019-12-30 16:08:43 -05:00
32 changed files with 498 additions and 127 deletions

27
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Node CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [10.x, 12.x]
steps:
- uses: actions/checkout@v1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: npm install, build, and test
run: |
npm install
npm run bootstrap
npm run build --if-present
npm test
env:
CI: true

View File

@@ -100,11 +100,13 @@ export N8N_CUSTOM_EXTENSIONS="/home/jim/n8n/custom-nodes;/data/n8n/nodes"
```
## Use built-in modules in Function-Nodes
## Use built-in and external modules in Function-Nodes
By default is it for security reasons not allowed to import modules in Function-Nodes.
It is, however, possible to lift that restriction for built-in modules by setting the
environment variable `NODE_FUNCTION_ALLOW_BUILTIN`.
For security reasons, importing modules is restricted by default in Function-Nodes.
It is, however, possible to lift that restriction for built-in and external modules by
setting the following environment variables:
`NODE_FUNCTION_ALLOW_BUILTIN`: For builtin modules
`NODE_FUNCTION_ALLOW_EXTERNAL`: For external modules sourced from n8n/node_modules directory. External module support is disabled when env variable is not set.
```bash
# Allows usage of all builtin modules
@@ -115,6 +117,9 @@ export NODE_FUNCTION_ALLOW_BUILTIN=crypto
# Allows usage of only crypto and fs
export NODE_FUNCTION_ALLOW_BUILTIN=crypto,fs
# Allow usage of external npm modules. Wildcard matching is not supported.
export NODE_FUNCTION_ALLOW_EXTERNAL=moment,lodash
```

View File

@@ -5,8 +5,7 @@ import {
} from "n8n-core";
import { Command, flags } from '@oclif/command';
const open = require('open');
import { promisify } from 'util';
import { dirname } from 'path';
// import { dirname } from 'path';
import * as config from '../config';
import {
@@ -20,7 +19,6 @@ import {
TestWebhooks,
} from "../src";
const tunnel = promisify(localtunnel);
// // Add support for internationalization
// const fullIcuPath = require.resolve('full-icu');
@@ -151,7 +149,7 @@ export class Start extends Command {
const port = config.get('port') as number;
// @ts-ignore
const webhookTunnel = await tunnel(port, tunnelSettings);
const webhookTunnel = await localtunnel(port, tunnelSettings);
process.env.WEBHOOK_TUNNEL_URL = webhookTunnel.url + '/';
this.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`);

View File

@@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.40.0",
"version": "0.41.0",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@@ -52,24 +52,24 @@
"devDependencies": {
"@oclif/dev-cli": "^1.22.2",
"@types/basic-auth": "^1.1.2",
"@types/compression": "0.0.36",
"@types/compression": "1.0.1",
"@types/connect-history-api-fallback": "^1.3.1",
"@types/convict": "^4.2.1",
"@types/dotenv": "^6.1.1",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.16.1",
"@types/jest": "^24.0.18",
"@types/localtunnel": "^1.9.0",
"@types/lodash.get": "^4.4.2",
"@types/lodash.get": "^4.4.6",
"@types/node": "^10.10.1",
"@types/open": "^6.1.0",
"@types/parseurl": "^1.3.1",
"@types/request-promise-native": "^1.0.15",
"jest": "^24.9.0",
"nodemon": "^1.19.1",
"nodemon": "^2.0.2",
"run-script-os": "^1.0.7",
"ts-jest": "^24.0.2",
"tslint": "^5.17.0",
"typescript": "~3.5.2"
"typescript": "~3.7.4"
},
"dependencies": {
"@oclif/command": "^1.5.18",
@@ -86,17 +86,17 @@
"flatted": "^2.0.0",
"glob-promise": "^3.4.0",
"google-timezones-json": "^1.0.2",
"inquirer": "^6.5.1",
"inquirer": "^7.0.1",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^1.6.0",
"localtunnel": "^1.9.1",
"localtunnel": "^2.0.0",
"lodash.get": "^4.4.2",
"mongodb": "^3.2.3",
"n8n-core": "~0.18.0",
"n8n-editor-ui": "~0.29.0",
"n8n-nodes-base": "~0.35.0",
"n8n-nodes-base": "~0.36.0",
"n8n-workflow": "~0.18.0",
"open": "^6.1.0",
"open": "^7.0.0",
"pg": "^7.11.0",
"request-promise-native": "^1.0.7",
"sqlite3": "^4.0.6",

View File

@@ -28,7 +28,7 @@
"@types/crypto-js": "^3.1.43",
"@types/express": "^4.16.1",
"@types/jest": "^24.0.18",
"@types/lodash.get": "^4.4.5",
"@types/lodash.get": "^4.4.6",
"@types/mmmagic": "^0.4.29",
"@types/node": "^10.10.1",
"@types/request-promise-native": "^1.0.15",
@@ -36,7 +36,7 @@
"source-map-support": "^0.5.9",
"ts-jest": "^24.0.2",
"tslint": "^5.17.0",
"typescript": "~3.5.2"
"typescript": "~3.7.4"
},
"dependencies": {
"crypto-js": "^3.1.9-1",

View File

@@ -5,7 +5,6 @@ module.exports = {
},
'extends': [
'plugin:vue/essential',
'@vue/standard',
'@vue/typescript',
],
rules: {
@@ -18,6 +17,6 @@ module.exports = {
'no-labels': 0,
},
parserOptions: {
parser: 'typescript-eslint-parser',
parser: '@typescript-eslint/parser',
},
};

View File

@@ -32,25 +32,29 @@
"@types/dateformat": "^3.0.0",
"@types/file-saver": "^2.0.1",
"@types/jest": "^24.0.18",
"@types/lodash.get": "^4.4.5",
"@types/lodash.get": "^4.4.6",
"@types/lodash.set": "^4.3.6",
"@types/node": "12.12.22",
"@types/quill": "^2.0.1",
"@vue/cli-plugin-babel": "^3.8.0",
"@vue/cli-plugin-e2e-cypress": "^3.8.0",
"@vue/cli-plugin-eslint": "^3.8.0",
"@vue/cli-plugin-typescript": "~3.8.1",
"@vue/cli-plugin-unit-jest": "^3.8.0",
"@vue/cli-service": "^3.8.0",
"@vue/eslint-config-standard": "^4.0.0",
"@vue/eslint-config-typescript": "~3.2.0",
"@vue/test-utils": "^1.0.0-beta.20",
"axios": "^0.18.1",
"@typescript-eslint/eslint-plugin": "^2.13.0",
"@typescript-eslint/parser": "^2.13.0",
"@vue/cli-plugin-babel": "^4.1.2",
"@vue/cli-plugin-e2e-cypress": "^4.1.2",
"@vue/cli-plugin-eslint": "^4.1.2",
"@vue/cli-plugin-typescript": "~4.1.2",
"@vue/cli-plugin-unit-jest": "^4.1.2",
"@vue/cli-service": "^4.1.2",
"@vue/eslint-config-standard": "^5.0.1",
"@vue/eslint-config-typescript": "~5.0.1",
"@vue/test-utils": "^1.0.0-beta.24",
"axios": "^0.19.0",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"dateformat": "^3.0.3",
"element-ui": "~2.4.11",
"eslint": "^5.8.0",
"eslint-plugin-vue": "^5.0.0-0",
"element-ui": "~2.13.0",
"eslint": "^6.8.0",
"eslint-plugin-import": "^2.19.1",
"eslint-plugin-vue": "^6.1.1",
"file-saver": "^2.0.2",
"flatted": "^2.0.0",
"jquery": "^3.4.1",
@@ -64,18 +68,18 @@
"prismjs": "^1.17.1",
"quill": "^2.0.0-dev.3",
"quill-autoformat": "^0.1.1",
"sass-loader": "^7.0.1",
"sass-loader": "^8.0.0",
"string-template-parser": "^1.2.6",
"ts-jest": "^24.0.2",
"tslint": "^5.17.0",
"typescript": "~3.5.2",
"typescript": "~3.7.4",
"vue": "^2.6.9",
"vue-cli-plugin-webpack-bundle-analyzer": "^1.3.0",
"vue-cli-plugin-webpack-bundle-analyzer": "^2.0.0",
"vue-json-tree": "^0.4.1",
"vue-prism-editor": "^0.3.0",
"vue-router": "^3.0.6",
"vue-template-compiler": "^2.5.17",
"vue-typed-mixins": "^0.1.0",
"vue-typed-mixins": "^0.2.0",
"vuex": "^3.1.1"
}
}

View File

@@ -21,14 +21,10 @@
</template>
<script lang="ts">
import Vue from 'vue';
import {
IBinaryData,
IBinaryKeyData,
IRunData,
IRunExecutionData,
Workflow,
} from 'n8n-workflow';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';

View File

@@ -37,8 +37,6 @@
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import { ICredentialsResponse } from '@/Interface';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
@@ -72,7 +70,7 @@ export default mixins(
};
},
watch: {
dialogVisible (newValue, oldValue) {
dialogVisible (newValue) {
if (newValue) {
this.loadCredentials();
this.loadCredentialTypes();
@@ -146,9 +144,8 @@ export default mixins(
return;
}
let result;
try {
result = await this.restApi().deleteCredentials(credential.id!);
await this.restApi().deleteCredentials(credential.id!);
} catch (error) {
this.$showError(error, 'Problem deleting credentials', 'There was a problem deleting the credentials:');
return;

View File

@@ -160,6 +160,7 @@
<script lang="ts">
import Vue from 'vue';
import { MessageBoxInputData } from 'element-ui/types/message-box';
import {
IExecutionResponse,
@@ -349,7 +350,7 @@ export default mixins(
cancelButtonText: 'Cancel',
inputErrorMessage: 'Invalid URL',
inputPattern: /^http[s]?:\/\/.*\.json$/i,
});
}) as MessageBoxInputData;
this.$root.$emit('importWorkflowUrl', { url: promptResponse.value });
} catch (e) {}
@@ -361,7 +362,7 @@ export default mixins(
inputValue: this.workflowName,
confirmButtonText: 'Rename',
cancelButtonText: 'Cancel',
}
},
)
.then((data) => {
// @ts-ignore

View File

@@ -10,7 +10,7 @@
<prism-editor v-if="!codeEditDialogVisible" :lineNumbers="true" :readonly="true" :code="displayValue" language="js"></prism-editor>
</div>
<el-input v-else ref="inputField" size="small" :type="getStringInputType" :rows="getArgument('rows')" :value="displayValue" :disabled="isReadOnly" @change="valueChanged" @keydown.stop @focus="setFocus" :title="displayTitle" :placeholder="isValueExpression?'':parameter.placeholder">
<el-input v-else v-model="tempValue" ref="inputField" size="small" :type="getStringInputType" :rows="getArgument('rows')" :value="displayValue" :disabled="isReadOnly" @change="valueChanged" @keydown.stop @focus="setFocus" :title="displayTitle" :placeholder="isValueExpression?'':parameter.placeholder">
<font-awesome-icon v-if="!isValueExpression && !isReadOnly" slot="suffix" icon="external-link-alt" class="edit-window-button clickable" title="Open Edit Window" @click="displayEditDialog()" />
</el-input>
</div>
@@ -83,7 +83,7 @@
<div v-else-if="parameter.type === 'color'" ref="inputField" class="color-input">
<el-color-picker :value="displayValue" :disabled="isReadOnly" @change="valueChanged" size="small" class="color-picker" @focus="setFocus" :title="displayTitle" ></el-color-picker>
<el-input size="small" type="text" :value="displayValue" :disabled="isReadOnly" @change="valueChanged" @keydown.stop @focus="setFocus" :title="displayTitle" ></el-input>
<el-input v-model="tempValue" size="small" type="text" :value="displayValue" :disabled="isReadOnly" @change="valueChanged" @keydown.stop @focus="setFocus" :title="displayTitle" ></el-input>
</div>
<div v-else-if="parameter.type === 'boolean'">
@@ -173,7 +173,7 @@ export default mixins(
remoteParameterOptionsLoading: false,
remoteParameterOptionsLoadingIssues: null as string | null,
textEditDialogVisible: false,
tempValue: '', // el-date-picker does not seem to work without v-model so add one
tempValue: '', // el-date-picker and el-input does not seem to work without v-model so add one
dateTimePickerOptions: {
shortcuts: [
{

View File

@@ -123,7 +123,7 @@ export default mixins(
},
getArgument (
argumentName: string,
parameter: INodeProperties
parameter: INodeProperties,
): string | number | boolean | undefined {
if (parameter.typeOptions === undefined) {
return undefined;

View File

@@ -190,7 +190,7 @@ export default mixins(
key: fullpath,
allowParentSelect: true,
dataType: 'array',
} as IVariableSelectorOption
} as IVariableSelectorOption,
);
} else if (typeof inputData === 'object') {
const tempValue: IVariableSelectorOption[] = [];
@@ -207,7 +207,7 @@ export default mixins(
key: fullpath,
allowParentSelect: true,
dataType: 'object',
} as IVariableSelectorOption
} as IVariableSelectorOption,
);
}
} else {
@@ -222,7 +222,7 @@ export default mixins(
name: propertyName,
key: fullpath,
value: inputData,
} as IVariableSelectorOption
} as IVariableSelectorOption,
);
}
}
@@ -294,7 +294,7 @@ export default mixins(
{
name: 'JSON',
options: this.sortOptions(jsonDataOptions),
}
},
);
}
}
@@ -321,7 +321,7 @@ export default mixins(
name: propertyName,
key: `$node["${nodeName}"].binary.${dataPropertyName}.${propertyName}`,
value: outputData.binary![dataPropertyName][propertyName],
}
},
);
}
@@ -332,7 +332,7 @@ export default mixins(
key: `$node["${nodeName}"].binary.${dataPropertyName}`,
options: this.sortOptions(binaryPropertyData),
allowParentSelect: true,
}
},
);
}
}
@@ -343,7 +343,7 @@ export default mixins(
key: `$node["${nodeName}"].binary`,
options: this.sortOptions(binaryData),
allowParentSelect: true,
}
},
);
}
}
@@ -476,7 +476,7 @@ export default mixins(
{
name: 'Input Data',
options: this.sortOptions(tempOutputData),
}
},
);
} else {
// Data is to large so do not add
@@ -488,7 +488,7 @@ export default mixins(
name: '[Data to large]',
},
],
}
},
);
}
}
@@ -504,14 +504,14 @@ export default mixins(
{
name: 'Parameters',
options: this.sortOptions(this.getNodeParameters(activeNode.name, initialPath, skipParameter, filterText) as IVariableSelectorOption[]),
}
},
);
returnData.push(
{
name: 'Current Node',
options: this.sortOptions(currentNodeData),
}
},
);
// Add the input data
@@ -562,7 +562,7 @@ export default mixins(
{
name: 'Output Data',
options: this.sortOptions(tempOutputData),
} as IVariableSelectorOption
} as IVariableSelectorOption,
);
}
}
@@ -571,7 +571,7 @@ export default mixins(
{
name: nodeName,
options: this.sortOptions(nodeOptions),
}
},
);
}
@@ -579,7 +579,7 @@ export default mixins(
{
name: 'Nodes',
options: this.sortOptions(allNodesData),
}
},
);
// Remove empty entries and return

View File

@@ -158,7 +158,7 @@ export default mixins(
});
},
},
}
},
);
</script>

View File

@@ -104,13 +104,13 @@ export default mixins(
workflowData.updatedAt = this.convertToDisplayDate(workflowData.updatedAt as number);
});
this.isDataLoading = false;
}
},
)
.catch(
(error: Error) => {
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:');
this.isDataLoading = false;
}
},
);
},
workflowActiveChanged (data: { id: string, active: boolean }) {

View File

@@ -185,7 +185,7 @@ export default mixins(
key: 'none',
value: 'Do not save',
},
]
],
);
},
async loadSaveDataSuccessExecutionOptions () {
@@ -204,7 +204,7 @@ export default mixins(
key: 'none',
value: 'Do not save',
},
]
],
);
},
async loadSaveManualOptions () {

View File

@@ -63,7 +63,7 @@ export const genericHelpers = mixins(showMessage).extend({
text: 'Loading',
spinner: 'el-icon-loading',
background: 'rgba(255, 255, 255, 0.8)',
}
},
);
},
stopLoading () {

View File

@@ -122,7 +122,7 @@ export const pushConnection = mixins(
// @ts-ignore
receivedData = JSON.parse(event.data);
} catch (error) {
console.error('The received push data is not valid JSON.');
console.error('The received push data is not valid JSON.'); // eslint-disable-line no-console
return;
}

View File

@@ -1,7 +1,7 @@
import Vue from 'vue';
import { parse } from 'flatted';
import axios, { AxiosRequestConfig } from 'axios';
import axios, { AxiosRequestConfig, Method } from 'axios';
import {
IActivationError,
ICredentialsDecryptedResponse,
@@ -94,7 +94,7 @@ export const restApi = Vue.extend({
restApi (): IRestApi {
const self = this;
return {
async makeRestApiRequest (method: string, endpoint: string, data?: IDataObject): Promise<any> { // tslint:disable-line:no-any
async makeRestApiRequest (method: Method, endpoint: string, data?: IDataObject): Promise<any> { // tslint:disable-line:no-any
try {
const options: AxiosRequestConfig = {
method,

View File

@@ -370,7 +370,7 @@ export const workflowHelpers = mixins(
{
confirmButtonText: 'Save',
cancelButtonText: 'Cancel',
}
},
)
.then((data) => {
// @ts-ignore
@@ -403,7 +403,7 @@ export const workflowHelpers = mixins(
if (currentWorkflow === undefined || withNewName === true) {
// Workflow is new or is supposed to get saved under a new name
// so create a new entry in database
workflowData.name = workflowName.trim() as string;
workflowData.name = workflowName!.trim() as string;
if (withNewName === true) {
// If an existing workflow gets resaved with a new name

View File

@@ -29,7 +29,7 @@ export const workflowSave = mixins(
{
confirmButtonText: 'Save',
cancelButtonText: 'Cancel',
}
},
)
.then((data) => {
// @ts-ignore
@@ -62,7 +62,7 @@ export const workflowSave = mixins(
if (currentWorkflow === undefined || withNewName === true) {
// Workflow is new or is supposed to get saved under a new name
// so create a new entry in database
workflowData.name = workflowName.trim() as string;
workflowData.name = workflowName!.trim() as string;
if (withNewName === true) {
// If an existing workflow gets resaved with a new name

View File

@@ -34,6 +34,7 @@ import {
faClone,
faCloud,
faCopy,
faCut,
faDotCircle,
faEdit,
faEnvelope,
@@ -106,6 +107,7 @@ library.add(faCogs);
library.add(faClone);
library.add(faCloud);
library.add(faCopy);
library.add(faCut);
library.add(faDotCircle);
library.add(faEdit);
library.add(faEnvelope);

View File

@@ -102,6 +102,7 @@
<script lang="ts">
import Vue from 'vue';
import { MessageBoxInputData } from 'element-ui/types/message-box';
import { jsPlumb, Endpoint, OnConnectionBindInfo } from 'jsplumb';
import { NODE_NAME_PREFIX, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste';
@@ -943,7 +944,7 @@ export default mixins(
// newNodeData.position = [activeNode.position[0], activeNode.position[1] + 60];
newNodeData.position = this.getNewNodePosition(
[lastSelectedNode.position[0] + 150, lastSelectedNode.position[1]],
[100, 0]
[100, 0],
);
} else {
// If no node is active find a free spot
@@ -970,7 +971,7 @@ export default mixins(
// Add connections of active node to newly created one
let connections = this.$store.getters.connectionsByNodeName(
lastSelectedNode.name
lastSelectedNode.name,
);
connections = JSON.parse(JSON.stringify(connections));
@@ -1406,7 +1407,7 @@ export default mixins(
newNodeData.position = this.getNewNodePosition(
[node.position[0], node.position[1] + 150],
[0, 150]
[0, 150],
);
await this.addNodes([newNodeData]);
@@ -1504,7 +1505,7 @@ export default mixins(
nameInput.select();
}
const promptResponse = await promptResponsePromise;
const promptResponse = await promptResponsePromise as MessageBoxInputData;
this.renameNode(currentName, promptResponse.value);
} catch (e) {}
@@ -1602,7 +1603,7 @@ export default mixins(
for (const type of Object.keys(connections[sourceNode])) {
for (let sourceIndex = 0; sourceIndex < connections[sourceNode][type].length; sourceIndex++) {
connections[sourceNode][type][sourceIndex].forEach((
targetData
targetData,
) => {
connectionData = [
{

View File

@@ -23,7 +23,7 @@ module.exports = {
css: {
loaderOptions: {
sass: {
data: `
prependData: `
@import "@/n8n-theme-variables.scss";
`,
},

View File

@@ -58,11 +58,11 @@
"change-case": "^3.1.0",
"copyfiles": "^2.1.1",
"inquirer": "^7.0.0",
"n8n-core": "^0.10.0",
"n8n-workflow": "^0.11.0",
"n8n-core": "^0.18.0",
"n8n-workflow": "^0.18.0",
"replace-in-file": "^4.1.0",
"request": "^2.88.0",
"tmp-promise": "^2.0.2",
"typescript": "~3.5.2"
"typescript": "~3.7.4"
}
}

View File

@@ -63,9 +63,8 @@ export class Function implements INodeType {
console: 'inherit',
sandbox,
require: {
external: false,
external: false as boolean | { modules: string[] },
builtin: [] as string[],
root: './',
}
};
@@ -73,6 +72,11 @@ export class Function implements INodeType {
options.require.builtin = process.env.NODE_FUNCTION_ALLOW_BUILTIN.split(',');
}
if (process.env.NODE_FUNCTION_ALLOW_EXTERNAL) {
options.require.external = { modules: process.env.NODE_FUNCTION_ALLOW_EXTERNAL.split(',') };
}
const vm = new NodeVM(options);
// Get the code to execute
@@ -80,7 +84,7 @@ export class Function implements INodeType {
try {
// Execute the function code
items = (await vm.run(`module.exports = async function() {${functionCode}}()`));
items = (await vm.run(`module.exports = async function() {${functionCode}}()`, './'));
} catch (e) {
return Promise.reject(e);
}

View File

@@ -93,7 +93,7 @@ export class GoogleSheet {
}
);
return response.data.values;
return response.data.values as string[][] | undefined;
}
@@ -141,9 +141,9 @@ export class GoogleSheet {
async batchUpdate(updateData: ISheetUpdateData[], valueInputMode: ValueInputOption) {
const client = await this.getAuthenticationClient();
// @ts-ignore
const response = await Sheets.spreadsheets.values.batchUpdate(
{
// @ts-ignore
auth: client,
spreadsheetId: this.id,
valueInputOption: valueInputMode,
@@ -163,6 +163,7 @@ export class GoogleSheet {
async setData(range: string, data: string[][], valueInputMode: ValueInputOption) {
const client = await this.getAuthenticationClient();
// @ts-ignore
const response = await Sheets.spreadsheets.values.update(
{
// @ts-ignore
@@ -186,9 +187,9 @@ export class GoogleSheet {
async appendData(range: string, data: string[][], valueInputMode: ValueInputOption) {
const client = await this.getAuthenticationClient();
// @ts-ignore
const response = await Sheets.spreadsheets.values.append(
{
// @ts-ignore
auth: client,
spreadsheetId: this.id,
range,

View File

@@ -0,0 +1,277 @@
import * as cheerio from 'cheerio';
import { IExecuteFunctions } from 'n8n-core';
import {
INodeExecutionData,
INodeType,
INodeTypeDescription,
IDataObject,
} from 'n8n-workflow';
interface IValueData {
attribute?: string;
cssSelector: string;
returnValue: string;
key: string;
returnArray: boolean;
}
// The extraction functions
const extractFunctions: {
[key: string]: ($: Cheerio, valueData: IValueData) => string | undefined;
} = {
attribute: ($: Cheerio, valueData: IValueData): string | undefined => $.attr(valueData.attribute!),
html: ($: Cheerio, valueData: IValueData): string | undefined => $.html() || undefined,
text: ($: Cheerio, valueData: IValueData): string | undefined => $.text(),
value: ($: Cheerio, valueData: IValueData): string | undefined => $.val(),
};
/**
* Simple helper function which applies options
*/
function getValue($: Cheerio, valueData: IValueData, options: IDataObject) {
const value = extractFunctions[valueData.returnValue]($, valueData);
if (options.trimValues === false || value === undefined) {
return value;
}
return value.trim();
}
export class HtmlExtract implements INodeType {
description: INodeTypeDescription = {
displayName: 'HTML Extract',
name: 'htmlExtract',
icon: 'fa:cut',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["sourceData"] + ": " + $parameter["dataPropertyName"]}}',
description: 'Extracts data from HTML',
defaults: {
name: 'HTML Extract',
color: '#333377',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Source Data',
name: 'sourceData',
type: 'options',
options: [
{
name: 'Binary',
value: 'binary',
},
{
name: 'JSON',
value: 'json',
},
],
default: 'json',
description: 'If HTML should be read from binary or json data.',
},
{
displayName: 'Binary Property',
name: 'dataPropertyName',
type: 'string',
displayOptions: {
show: {
sourceData: [
'binary',
],
},
},
default: 'data',
required: true,
description: 'Name of the binary property in which the HTML to extract the data from can be found.',
},
{
displayName: 'JSON Property',
name: 'dataPropertyName',
type: 'string',
displayOptions: {
show: {
sourceData: [
'json',
],
},
},
default: 'data',
required: true,
description: 'Name of the json property in which the HTML to extract the data from can be found.<br />The property can either contain a string or an array of strings.',
},
{
displayName: 'Extraction Values',
name: 'extractionValues',
placeholder: 'Add Value',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
description: 'The extraction values.',
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Key',
name: 'key',
type: 'string',
default: '',
description: 'The key under which the extracted value should be saved.',
},
{
displayName: 'CSS Selector',
name: 'cssSelector',
type: 'string',
default: '',
placeholder: '.price',
description: 'The CSS selector to use.',
},
{
displayName: 'Return Value',
name: 'returnValue',
type: 'options',
options: [
{
name: 'Attribute',
value: 'attribute',
description: 'Get an attribute value like "class" from an element.',
},
{
name: 'HTML',
value: 'html',
description: 'Get the HTML the element contains.',
},
{
name: 'Text',
value: 'text',
description: 'Get only the text content of the element.',
},
{
name: 'Value',
value: 'value',
description: 'Get value of an input, select or textarea.',
},
],
default: 'text',
description: 'What kind of data should be returned.',
},
{
displayName: 'Attribute',
name: 'attribute',
type: 'string',
displayOptions: {
show: {
returnValue: [
'attribute',
],
},
},
default: '',
placeholder: 'class',
description: 'The name of the attribute to return the value off.',
},
{
displayName: 'Return Array',
name: 'returnArray',
type: 'boolean',
default: false,
description: 'Returns the values as an array so if multiple ones get found they also get<br />returned separately.If not set all will be returned as a single string.',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Trim Values',
name: 'trimValues',
type: 'boolean',
default: true,
description: 'Removes automatically all spaces and newlines from<br />the beginning and end of the values.',
},
],
}
]
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
let item: INodeExecutionData;
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const dataPropertyName = this.getNodeParameter('dataPropertyName', itemIndex) as string;
const extractionValues = this.getNodeParameter('extractionValues', itemIndex) as IDataObject;
const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject;
const sourceData = this.getNodeParameter('sourceData', itemIndex) as string;
item = items[itemIndex];
let htmlArray: string[] | string = [];
if (sourceData === 'json') {
if (item.json[dataPropertyName] === undefined) {
throw new Error(`No property named "${dataPropertyName}" exists!`);
}
htmlArray = item.json[dataPropertyName] as string;
} else {
if (item.binary === undefined) {
throw new Error(`No item does not contain binary data!`);
}
if (item.binary[dataPropertyName] === undefined) {
throw new Error(`No property named "${dataPropertyName}" exists!`);
}
htmlArray = Buffer.from(item.binary[dataPropertyName].data, 'base64').toString('utf8');
}
// Convert it always to array that it works with a string or an array of strings
if (!Array.isArray(htmlArray)) {
htmlArray = [htmlArray];
}
for (const html of htmlArray as string[]) {
const $ = cheerio.load(html);
const newItem: INodeExecutionData = {
json: {},
};
// Itterate over all the defined values which should be extracted
let htmlElement;
for (const valueData of extractionValues.values as IValueData[]) {
htmlElement = $(valueData.cssSelector);
if (valueData.returnArray === true) {
// An array should be returned so itterate over one
// value at a time
newItem.json[valueData.key as string] = [];
htmlElement.each((i, el) => {
(newItem.json[valueData.key as string] as Array<string | undefined>).push(getValue($(el), valueData, options));
});
} else {
// One single value should be returned
newItem.json[valueData.key as string] = getValue(htmlElement, valueData, options);
}
}
returnData.push(newItem);
}
}
return this.prepareOutputData(returnData);
}
}

View File

@@ -25,6 +25,7 @@ export class HttpRequest implements INodeType {
icon: 'fa:at',
group: ['input'],
version: 1,
subtitle: '={{$parameter["requestMethod"] + ": " + $parameter["url"]}}',
description: 'Makes a HTTP request and returns the received data',
defaults: {
name: 'HTTP Request',

View File

@@ -109,27 +109,27 @@ export class SpreadsheetFile implements INodeType {
type: 'options',
options: [
{
name: 'csv',
name: 'CSV',
value: 'csv',
description: 'Comma-separated values',
},
{
name: 'ods',
value: 'ods',
description: 'OpenDocument Spreadsheet',
},
{
name: 'rtf',
value: 'rtf',
description: 'Rich Text Format',
},
{
name: 'html',
name: 'HTML',
value: 'html',
description: 'HTML Table',
},
{
name: 'xls',
name: 'ODS',
value: 'ods',
description: 'OpenDocument Spreadsheet',
},
{
name: 'RTF',
value: 'rtf',
description: 'Rich Text Format',
},
{
name: 'XLS',
value: 'xls',
description: 'Excel',
},
@@ -166,22 +166,68 @@ export class SpreadsheetFile implements INodeType {
name: 'options',
type: 'collection',
placeholder: 'Add Option',
displayOptions: {
show: {
operation: [
'toFile',
],
},
},
default: {},
options: [
{
displayName: 'File Name',
name: 'fileName',
type: 'string',
displayOptions: {
show: {
'/operation': [
'toFile',
],
},
},
default: '',
description: 'File name to set in binary data. By default will "spreadsheet.<fileFormat>" be used.',
},
{
displayName: 'RAW Data',
name: 'rawData',
type: 'boolean',
displayOptions: {
show: {
'/operation': [
'fromFile'
],
},
},
default: false,
description: 'If the data should be returned RAW instead of parsed.',
},
{
displayName: 'Sheet Name',
name: 'sheetName',
type: 'string',
displayOptions: {
show: {
'/operation': [
'fromFile',
],
},
},
default: 'Sheet',
description: 'Name of the sheet to read from in the spreadsheet (if supported). If not set, the first one gets chosen.',
},
{
displayName: 'Sheet Name',
name: 'sheetName',
type: 'string',
displayOptions: {
show: {
'/operation': [
'toFile',
],
'/fileFormat': [
'ods',
'xls',
],
},
},
default: 'Sheet',
description: 'Name of the sheet to create in the spreadsheet.',
},
],
},
]
@@ -203,7 +249,8 @@ export class SpreadsheetFile implements INodeType {
for (let i = 0; i < items.length; i++) {
item = items[i];
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', 0) as string;
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string;
const options = this.getNodeParameter('options', i, {}) as IDataObject;
if (item.binary === undefined || item.binary[binaryPropertyName] === undefined) {
// Property did not get found on item
@@ -212,14 +259,22 @@ export class SpreadsheetFile implements INodeType {
// Read the binary spreadsheet data
const binaryData = Buffer.from(item.binary[binaryPropertyName].data, BINARY_ENCODING);
const workbook = xlsxRead(binaryData);
const workbook = xlsxRead(binaryData, { raw: options.rawData as boolean });
if (workbook.SheetNames.length === 0) {
throw new Error('File does not have any sheets!');
throw new Error('Spreadsheet does not have any sheets!');
}
let sheetName = workbook.SheetNames[0];
if (options.sheetName) {
if (!workbook.SheetNames.includes(options.sheetName as string)) {
throw new Error(`Spreadsheet does not contain sheet called "${options.sheetName}"!`);
}
sheetName = options.sheetName as string;
}
// Convert it to json
const sheetJson = xlsxUtils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
const sheetJson = xlsxUtils.sheet_to_json(workbook.Sheets[sheetName]);
// Check if data could be found in file
if (sheetJson.length === 0) {
@@ -267,7 +322,7 @@ export class SpreadsheetFile implements INodeType {
}
// Convert the data in the correct format
const sheetName = 'Sheet';
const sheetName = options.sheetName as string || 'Sheet';
const wb: WorkBook = {
SheetNames: [sheetName],
Sheets: {

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-nodes-base",
"version": "0.35.0",
"version": "0.36.0",
"description": "Base nodes of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@@ -110,6 +110,7 @@
"dist/nodes/Google/GoogleDrive.node.js",
"dist/nodes/Google/GoogleSheets.node.js",
"dist/nodes/GraphQL/GraphQL.node.js",
"dist/nodes/HtmlExtract/HtmlExtract.node.js",
"dist/nodes/HttpRequest.node.js",
"dist/nodes/Hubspot/Hubspot.node.js",
"dist/nodes/If.node.js",
@@ -167,6 +168,7 @@
"devDependencies": {
"@types/aws4": "^1.5.1",
"@types/basic-auth": "^1.1.2",
"@types/cheerio": "^0.22.15",
"@types/cron": "^1.6.1",
"@types/express": "^4.16.1",
"@types/gm": "^1.18.2",
@@ -184,15 +186,16 @@
"n8n-workflow": "~0.18.0",
"ts-jest": "^24.0.2",
"tslint": "^5.17.0",
"typescript": "~3.5.2"
"typescript": "~3.7.4"
},
"dependencies": {
"aws4": "^1.8.0",
"basic-auth": "^2.0.1",
"cheerio": "^1.0.0-rc.3",
"cron": "^1.6.0",
"glob-promise": "^3.4.0",
"gm": "^1.23.1",
"googleapis": "^42.0.0",
"googleapis": "^46.0.0",
"imap-simple": "^4.3.0",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",

View File

@@ -27,12 +27,12 @@
"devDependencies": {
"@types/express": "^4.16.1",
"@types/jest": "^24.0.18",
"@types/lodash.get": "^4.4.5",
"@types/lodash.get": "^4.4.6",
"@types/node": "^10.10.1",
"jest": "^24.9.0",
"ts-jest": "^24.0.2",
"tslint": "^5.17.0",
"typescript": "~3.5.2"
"typescript": "~3.7.4"
},
"dependencies": {
"lodash.get": "^4.4.2",