This commit is contained in:
Rupenieks 2020-08-28 11:37:42 +02:00
commit 3a61ad5997
51 changed files with 3982 additions and 653 deletions

View file

@ -19,9 +19,10 @@ jobs:
run: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: crazy-max/ghaction-docker-buildx@v1
uses: crazy-max/ghaction-docker-buildx@v3
with:
version: latest
buildx-version: latest
qemu-version: latest
- name: Run Buildx (push image)
if: success()
run: |

View file

@ -2,6 +2,26 @@
This list shows all the versions which include breaking changes and how to upgrade.
## 0.79.0
### What changed?
We have renamed the operations in the Todoist Node for consistency with the codebase. We also deleted the `close_match` and `delete_match` operations as these can be accomplished using the following operations: `getAll`, `close`, and `delete`.
### When is action necessary?
When one of the following operations is used:
- close_by
- close_match
- delete_id
- delete_match
### How to upgrade:
After upgrading, open all workflows which contain the Todoist Node. Set the corresponding operation, and then save the workflow.
If the operations `close_match` or `delete_match` are used, recreate them using the operations: `getAll`, `delete`, and `close`.
## 0.69.0

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.78.0",
"version": "0.79.0",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -101,8 +101,8 @@
"mongodb": "^3.5.5",
"mysql2": "^2.0.1",
"n8n-core": "~0.43.0",
"n8n-editor-ui": "~0.54.0",
"n8n-nodes-base": "~0.73.0",
"n8n-editor-ui": "~0.55.0",
"n8n-nodes-base": "~0.74.0",
"n8n-workflow": "~0.39.0",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",

View file

@ -213,64 +213,65 @@ class App {
const jwtAuthHeader = await GenericHelpers.getConfigValue('security.jwtAuth.jwtHeader') as string;
if (jwtAuthHeader === '') {
throw new Error('JWT auth is activated but no request header was defined. Please set one!');
}
}
const jwksUri = await GenericHelpers.getConfigValue('security.jwtAuth.jwksUri') as string;
if (jwksUri === '') {
throw new Error('JWT auth is activated but no JWK Set URI was defined. Please set one!');
}
const jwtHeaderValuePrefix = await GenericHelpers.getConfigValue('security.jwtAuth.jwtHeaderValuePrefix') as string;
const jwtIssuer = await GenericHelpers.getConfigValue('security.jwtAuth.jwtIssuer') as string;
const jwtNamespace = await GenericHelpers.getConfigValue('security.jwtAuth.jwtNamespace') as string;
const jwtAllowedTenantKey = await GenericHelpers.getConfigValue('security.jwtAuth.jwtAllowedTenantKey') as string;
const jwtAllowedTenant = await GenericHelpers.getConfigValue('security.jwtAuth.jwtAllowedTenant') as string;
}
const jwtHeaderValuePrefix = await GenericHelpers.getConfigValue('security.jwtAuth.jwtHeaderValuePrefix') as string;
const jwtIssuer = await GenericHelpers.getConfigValue('security.jwtAuth.jwtIssuer') as string;
const jwtNamespace = await GenericHelpers.getConfigValue('security.jwtAuth.jwtNamespace') as string;
const jwtAllowedTenantKey = await GenericHelpers.getConfigValue('security.jwtAuth.jwtAllowedTenantKey') as string;
const jwtAllowedTenant = await GenericHelpers.getConfigValue('security.jwtAuth.jwtAllowedTenant') as string;
function isTenantAllowed(decodedToken: object): Boolean {
if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '') return true;
else {
for (let [k, v] of Object.entries(decodedToken)) {
if (k === jwtNamespace) {
for (let [kn, kv] of Object.entries(v)) {
if (kn === jwtAllowedTenantKey && kv === jwtAllowedTenant) {
return true;
}
}
}
}
}
return false;
}
function isTenantAllowed(decodedToken: object): boolean {
if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '') return true;
else {
for (const [k, v] of Object.entries(decodedToken)) {
if (k === jwtNamespace) {
for (const [kn, kv] of Object.entries(v)) {
if (kn === jwtAllowedTenantKey && kv === jwtAllowedTenant) {
return true;
}
}
}
}
}
return false;
}
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.url.match(authIgnoreRegex)) {
return next();
}
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.url.match(authIgnoreRegex)) {
return next();
}
var token = req.header(jwtAuthHeader) as string;
if (token === undefined || token === '') {
return ResponseHelper.jwtAuthAuthorizationError(res, "Missing token");
}
if (jwtHeaderValuePrefix != '' && token.startsWith(jwtHeaderValuePrefix)) {
token = token.replace(jwtHeaderValuePrefix + ' ', '').trimLeft();
}
let token = req.header(jwtAuthHeader) as string;
if (token === undefined || token === '') {
return ResponseHelper.jwtAuthAuthorizationError(res, "Missing token");
}
if (jwtHeaderValuePrefix != '' && token.startsWith(jwtHeaderValuePrefix)) {
token = token.replace(jwtHeaderValuePrefix + ' ', '').trimLeft();
}
const jwkClient = jwks({ cache: true, jwksUri });
function getKey(header: any, callback: Function) { // tslint:disable-line:no-any
jwkClient.getSigningKey(header.kid, (err: Error, key: any) => { // tslint:disable-line:no-any
if (err) throw ResponseHelper.jwtAuthAuthorizationError(res, err.message);
const jwkClient = jwks({ cache: true, jwksUri });
function getKey(header: any, callback: Function) { // tslint:disable-line:no-any
jwkClient.getSigningKey(header.kid, (err: Error, key: any) => { // tslint:disable-line:no-any
if (err) throw ResponseHelper.jwtAuthAuthorizationError(res, err.message);
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
var jwtVerifyOptions: jwt.VerifyOptions = {
issuer: jwtIssuer != '' ? jwtIssuer : undefined,
ignoreExpiration: false
}
jwt.verify(token, getKey, jwtVerifyOptions, (err: jwt.VerifyErrors, decoded: object) => {
if (err) ResponseHelper.jwtAuthAuthorizationError(res, 'Invalid token');
else if (!isTenantAllowed(decoded)) ResponseHelper.jwtAuthAuthorizationError(res, 'Tenant not allowed');
else next();
const jwtVerifyOptions: jwt.VerifyOptions = {
issuer: jwtIssuer !== '' ? jwtIssuer : undefined,
ignoreExpiration: false
};
jwt.verify(token, getKey, jwtVerifyOptions, (err: jwt.VerifyErrors, decoded: object) => {
if (err) ResponseHelper.jwtAuthAuthorizationError(res, 'Invalid token');
else if (!isTenantAllowed(decoded)) ResponseHelper.jwtAuthAuthorizationError(res, 'Tenant not allowed');
else next();
});
});
}
@ -296,6 +297,8 @@ class App {
// Make sure that each request has the "parsedUrl" parameter
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
(req as ICustomRequest).parsedUrl = parseUrl(req);
// @ts-ignore
req.rawBody = new Buffer('', 'base64');
next();
});
@ -309,11 +312,13 @@ class App {
// Support application/xml type post data
// @ts-ignore
this.app.use(bodyParser.xml({ limit: '16mb', xmlParseOptions: {
normalize: true, // Trim whitespace inside text nodes
normalizeTags: true, // Transform tags to lowercase
explicitArray: false, // Only put properties in array if length > 1
} }));
this.app.use(bodyParser.xml({
limit: '16mb', xmlParseOptions: {
normalize: true, // Trim whitespace inside text nodes
normalizeTags: true, // Transform tags to lowercase
explicitArray: false, // Only put properties in array if length > 1
}
}));
this.app.use(bodyParser.text({
limit: '16mb', verify: (req, res, buf) => {
@ -1008,8 +1013,8 @@ class App {
hash_function(base, key) {
const algorithm = (signatureMethod === 'HMAC-SHA1') ? 'sha1' : 'sha256';
return createHmac(algorithm, key)
.update(base)
.digest('base64');
.update(base)
.digest('base64');
},
});
@ -1209,7 +1214,7 @@ class App {
// Verify and store app code. Generate access tokens and store for respective credential.
this.app.get(`/${this.restEndpoint}/oauth2-credential/callback`, async (req: express.Request, res: express.Response) => {
const {code, state: stateEncoded } = req.query;
const { code, state: stateEncoded } = req.query;
if (code === undefined || stateEncoded === undefined) {
const errorResponse = new ResponseHelper.ResponseError('Insufficient parameters for OAuth2 callback. Received following query parameters: ' + JSON.stringify(req.query), undefined, 503);
@ -1643,7 +1648,7 @@ class App {
response = await this.activeWorkflowRunner.executeWebhook('GET', requestUrl, req, res);
} catch (error) {
ResponseHelper.sendErrorResponse(res, error);
return ;
return;
}
if (response.noWebhookResponse === true) {
@ -1835,13 +1840,13 @@ export async function start(): Promise<void> {
let server;
if (app.protocol === 'https' && app.sslKey && app.sslCert){
if (app.protocol === 'https' && app.sslKey && app.sslCert) {
const https = require('https');
const privateKey = readFileSync(app.sslKey, 'utf8');
const cert = readFileSync(app.sslCert, 'utf8');
const credentials = { key: privateKey,cert };
server = https.createServer(credentials,app.app);
}else{
const credentials = { key: privateKey, cert };
server = https.createServer(credentials, app.app);
} else {
const http = require('http');
server = http.createServer(app.app);
}

View file

@ -50,20 +50,20 @@ export class ActiveWebhooks {
// it gets called
this.webhookUrls[webhookKey] = webhookData;
const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, this.testWebhooks);
if (webhookExists === false) {
// If webhook does not exist yet create it
try {
try {
const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, this.testWebhooks);
if (webhookExists === false) {
// If webhook does not exist yet create it
await workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, this.testWebhooks);
} catch (error) {
// If there was a problem unregister the webhook again
delete this.webhookUrls[webhookKey];
delete this.workflowWebhooks[webhookData.workflowId];
throw error;
}
}
} catch (error) {
// If there was a problem unregister the webhook again
delete this.webhookUrls[webhookKey];
delete this.workflowWebhooks[webhookData.workflowId];
throw error;
}
this.workflowWebhooks[webhookData.workflowId].push(webhookData);
}

View file

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "0.54.0",
"version": "0.55.0",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",

View file

@ -417,3 +417,5 @@ export interface ITimeoutHMS {
minutes: number;
seconds: number;
}
export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR';

View file

@ -1,7 +1,29 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" append-to-body width="55%" :title="title" :before-close="closeDialog">
<el-dialog :visible="dialogVisible" append-to-body width="75%" class="credentials-edit-wrapper" :title="title" :nodeType="nodeType" :before-close="closeDialog">
<div name="title" class="title-container" slot="title">
<div class="title-left">{{title}}</div>
<div class="title-right">
<div v-if="credentialType" class="docs-container">
<svg class="help-logo" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
<g transform="translate(10.000000, 11.000000)">
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
</g>
<rect x="0" y="0" width="18" height="18"></rect>
</g>
</g>
</g>
</g>
</svg>
<span v-if="credentialType" class="doc-link-text">Need help? <a class="doc-hyperlink" :href="'https://docs.n8n.io/credentials/' + documentationUrl + '/?utm_source=n8n_app&utm_medium=left_nav_menu&utm_campaign=create_new_credentials_modal'" target="_blank">Open credential docs</a></span>
</div>
</div>
</div>
<div class="credential-type-item">
<el-row v-if="!setCredentialType">
<el-col :span="6">
@ -40,9 +62,11 @@ import {
NodeHelpers,
ICredentialType,
INodeProperties,
INodeTypeDescription,
} from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import { INodeUi } from '../Interface';
export default mixins(
restApi,
@ -85,6 +109,39 @@ export default mixins(
}
}
},
documentationUrl (): string {
if (this.editCredentials) {
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
if (credentialType.documentationUrl === undefined) {
return credentialType.name;
} else {
return `${credentialType.documentationUrl}`;
}
} else {
if (this.credentialType) {
const credentialType = this.$store.getters.credentialType(this.credentialType);
if (credentialType.documentationUrl === undefined) {
return credentialType.name;
} else {
return `${credentialType.documentationUrl}`;
}
} else {
return '';
}
}
},
node (): INodeUi {
return this.$store.getters.activeNode;
},
nodeType (): INodeTypeDescription | null {
const activeNode = this.node;
if (this.node) {
return this.$store.getters.nodeType(this.node.type);
}
return null;
},
},
watch: {
async dialogVisible (newValue, oldValue): Promise<void> {
@ -214,7 +271,7 @@ export default mixins(
this.$showMessage({
title: 'Credentials created',
message: `The credential "${eventData.data.name}" got created!`,
message: `"${eventData.data.name}" credentials were successfully created!`,
type: 'success',
});
@ -227,7 +284,7 @@ export default mixins(
this.$showMessage({
title: 'Credentials updated',
message: `The credential "${eventData.data.name}" got updated!`,
message: `"${eventData.data.name}" credentials were successfully updated!`,
type: 'success',
});
@ -245,9 +302,79 @@ export default mixins(
</script>
<style lang="scss">
.credentials-edit-wrapper {
.credential-type-item {
padding-bottom: 1em;
}
.credential-type-item {
padding-bottom: 1em;
@media (min-width: 1200px){
.title-container {
display: flex;
flex-direction: row;
max-width: 100%;
line-height: 17px;
}
.docs-container {
margin-left: auto;
margin-right: 0;
}
}
@media (max-width: 1199px){
.title-container {
display: flex;
flex-direction: column;
max-width: 100%;
line-height: 17px;
}
.docs-container {
margin-top: 10px;
margin-left: 0;
margin-right: auto;
}
}
.title-left {
flex: 7;
font-size: 16px;
font-weight: bold;
color: #7a7a7a;
vertical-align:middle;
}
.title-right {
vertical-align: middle;
flex: 3;
font-family: "Open Sans";
color: #666666;
font-size: 12px;
font-weight: 510;
letter-spacing: 0;
display: flex;
flex-direction: row;
min-width: 40%;
}
.help-logo {
flex: 1;
}
.doc-link-text {
margin-left: 2px;
float: right;
word-break: break-word;
flex: 9;
}
.doc-hyperlink,
.doc-hyperlink:visited,
.doc-hyperlink:focus,
.doc-hyperlink:active {
text-decoration: none;
color: #FF6150;
}
}
</style>

View file

@ -44,19 +44,19 @@
<el-button title="Connect OAuth Credentials" circle :disabled="true">
<font-awesome-icon icon="redo" />
</el-button>
Not all required credential properties are filled
Enter all required properties
</span>
<span v-else-if="isOAuthConnected === true">
<el-button title="Reconnect OAuth Credentials" @click.stop="oAuthCredentialAuthorize()" circle>
<font-awesome-icon icon="redo" />
</el-button>
Is connected
Connected
</span>
<span v-else>
<el-button title="Connect OAuth Credentials" @click.stop="oAuthCredentialAuthorize()" circle>
<font-awesome-icon icon="sign-in-alt" />
</el-button>
Is NOT connected
Not connected
</span>
<div v-if="credentialProperties.length">
@ -91,7 +91,7 @@
<div v-if="nodesAccess.length === 0" class="no-nodes-access">
<strong>
Important!
Important
</strong><br />
Add at least one node which has access to the credentials!
</div>
@ -163,8 +163,8 @@ export default mixins(
isMinimized: true,
helpTexts: {
credentialsData: 'The credentials to set.',
credentialsName: 'The name the credentials should be saved as. Use a name<br />which makes it clear to what exactly they give access to.<br />For credentials of an Email account that could be the Email address itself.',
nodesWithAccess: 'The nodes which allowed to use this credentials.',
credentialsName: 'A recognizable label for the credentials. Descriptive names work <br />best here, so you can easily select it from a list later.',
nodesWithAccess: 'Nodes with access to these credentials.',
},
credentialDataTemp: null as ICredentialsDecryptedResponse | null,
nodesAccess: [] as string[],
@ -256,7 +256,7 @@ export default mixins(
this.$showMessage({
title: 'Copied',
message: `The callback URL got copied!`,
message: `Callback URL was successfully copied!`,
type: 'success',
});
},
@ -401,7 +401,7 @@ export default mixins(
this.$showMessage({
title: 'Connected',
message: 'Got connected!',
message: 'Connected successfully!',
type: 'success',
});
}

View file

@ -124,7 +124,7 @@ export default mixins(
try {
this.credentials = JSON.parse(JSON.stringify(this.$store.getters.allCredentials));
} catch (error) {
this.$showError(error, 'Proble loading credentials', 'There was a problem loading the credentials:');
this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:');
this.isDataLoading = false;
return;
}
@ -138,7 +138,7 @@ export default mixins(
},
async deleteCredential (credential: ICredentialsResponse) {
const deleteConfirmed = await this.confirmMessage(`Are you sure that you want to delete the credentials "${credential.name}"?`, 'Delete Credentials?', 'warning', 'Yes, delete!');
const deleteConfirmed = await this.confirmMessage(`Are you sure you want to delete "${credential.name}" credentials?`, 'Delete Credentials?', 'warning', 'Yes, delete!');
if (deleteConfirmed === false) {
return;

View file

@ -9,16 +9,16 @@
</div>
<transition name="fade">
<div v-if="showDocumentHelp && nodeType" class="doc-help-wrapper">
<svg id="help-logo" v-if="showDocumentHelp && nodeType" :href="'https://docs.n8n.io/nodes/' + nodeType.name" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<svg id="help-logo" v-if="showDocumentHelp && nodeType" :href="'https://docs.n8n.io/nodes/' + nodeType.name" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<g id="MVP-Onboard-proposal" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Node-modal-(docs-link)" transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g id="Group" transform="translate(1117.000000, 825.000000)">
<g id="mdi-help-box" transform="translate(10.000000, 11.000000)">
<g id="Icon" transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z" id="Icon-Shape"></path>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
<g transform="translate(10.000000, 11.000000)">
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
</g>
<rect id="ViewBox" x="0" y="0" width="18" height="18"></rect>
<rect x="0" y="0" width="18" height="18"></rect>
</g>
</g>
</g>

View file

@ -12,14 +12,15 @@
<font-awesome-icon icon="check" class="execution-icon success" v-if="executionFinished" title="Execution was successful" />
<font-awesome-icon icon="times" class="execution-icon error" v-else title="Execution did fail" />
</span>
of Workflow
of
<span class="workflow-name clickable" title="Open Workflow">
<span @click="openWorkflow(workflowExecution.workflowId)">"{{workflowName}}"</span>
</span>
workflow
</span>
<span index="workflow-name" class="current-workflow" v-if="!isReadOnly">
<span v-if="currentWorkflow">Workflow: <span class="workflow-name">{{workflowName}}</span></span>
<span v-else class="workflow-not-saved">Workflow not saved!</span>
<span v-else class="workflow-not-saved">Workflow was not saved!</span>
</span>
<span class="saving-workflow" v-if="isWorkflowSaving">
@ -32,9 +33,9 @@
<div class="push-connection-lost" v-if="!isPushConnectionActive">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
Server connection could not be established.<br />
The server is down or there is a connection problem.<br />
It will reconnect automatically as soon as the backend can be reached.
Cannot connect to server.<br />
It is either down or you have a connection issue. <br />
It should reconnect automatically once the issue is resolved.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />&nbsp;
@ -50,9 +51,8 @@
<div class="read-only" v-if="isReadOnly">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
A past execution gets displayed. For that reason no data<br />
can be changed. To make changes or to execute it again open<br />
the workflow by clicking on it`s name on the left.
You're viewing the log of a previous execution. You cannot<br />
make changes since this execution already occured. Make changes<br /> to this workflow by clicking on it`s name on the left.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />
@ -84,6 +84,7 @@ import { genericHelpers } from '@/components/mixins/genericHelpers';
import { pushConnection } from '@/components/mixins/pushConnection';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { saveAs } from 'file-saver';
@ -95,6 +96,7 @@ export default mixins(
pushConnection,
restApi,
showMessage,
titleChange,
workflowHelpers,
)
.extend({
@ -155,6 +157,7 @@ export default mixins(
},
methods: {
async openWorkflow (workflowId: string) {
this.$titleSet(this.workflowName, 'IDLE');
// Change to other workflow
this.$router.push({
name: 'NodeViewExisting',
@ -162,7 +165,6 @@ export default mixins(
});
},
},
async mounted () {
// Initialize the push connection
this.pushConnect();

View file

@ -179,6 +179,7 @@ import WorkflowSettings from '@/components/WorkflowSettings.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowSave } from '@/components/mixins/workflowSave';
import { workflowRun } from '@/components/mixins/workflowRun';
@ -191,6 +192,7 @@ export default mixins(
genericHelpers,
restApi,
showMessage,
titleChange,
workflowHelpers,
workflowRun,
workflowSave,
@ -417,7 +419,8 @@ export default mixins(
this.$showError(error, 'Problem deleting the workflow', 'There was a problem deleting the workflow:');
return;
}
// Reset tab title since workflow is deleted.
this.$titleReset();
this.$showMessage({
title: 'Workflow got deleted',
message: `The workflow "${this.workflowName}" got deleted!`,

View file

@ -13,7 +13,7 @@
<div class="node-create-list-wrapper">
<div class="node-create-list">
<div v-if="filteredNodeTypes.length === 0" class="no-results">
No node found which matches active filter!
🙃 no nodes matching your search criteria
</div>
<node-create-item :active="index === activeNodeTypeIndex" :nodeType="nodeType" v-for="(nodeType, index) in filteredNodeTypes" v-bind:key="nodeType.name" @nodeTypeSelected="nodeTypeSelected"></node-create-item>
</div>

View file

@ -5,7 +5,7 @@
<display-with-change :key-name="'name'" @valueChanged="valueChanged"></display-with-change>
<a v-if="nodeType" :href="'http://n8n.io/nodes/' + nodeType.name" target="_blank" class="node-info">
<el-tooltip class="clickable" placement="top" effect="light">
<div slot="content" v-html="'<strong>Node Description:</strong><br />' + nodeTypeDescription + '<br /><br /><strong>For more information and usage examples click!</strong>'"></div>
<div slot="content" v-html="'<strong>Node Description:</strong><br />' + nodeTypeDescription + '<br /><br /><strong>Click the \'?\' icon to open this node on n8n.io </strong>'"></div>
<font-awesome-icon icon="question-circle" />
</el-tooltip>
</a>
@ -22,7 +22,7 @@
<node-webhooks :node="node" :nodeType="nodeType" />
<parameter-input-list :parameters="parametersNoneSetting" :hideDelete="true" :nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged" />
<div v-if="parametersNoneSetting.length === 0">
The node does not have any parameters.
This node does not have any parameters.
</div>
</el-tab-pane>
<el-tab-pane label="Settings">
@ -162,15 +162,15 @@ export default mixins(
},
default: '',
noDataExpression: true,
description: 'Notes to save with the node.',
description: 'Optional note to save with the node.',
},
{
displayName: 'Notes In Flow',
displayName: 'Display note in flow?',
name: 'notesInFlow',
type: 'boolean',
default: false,
noDataExpression: true,
description: 'If activated it will display the above notes in the flow as subtitle.',
description: 'If active, the note above will display in the flow as a subtitle.',
},
{
displayName: 'Node Color',
@ -186,7 +186,7 @@ export default mixins(
type: 'boolean',
default: false,
noDataExpression: true,
description: 'If activated and the node does not have any data for the first output,<br />it returns an empty item anyway. Be careful setting this on<br />IF-Nodes as it could easily cause an infinite loop.',
description: 'If active, the node will return an empty item even if the <br />node returns no data during an initial execution. Be careful setting <br />this on IF-Nodes as it could cause an infinite loop.',
},
{
displayName: 'Execute Once',
@ -194,7 +194,7 @@ export default mixins(
type: 'boolean',
default: false,
noDataExpression: true,
description: 'Instead of executing once per item does it only execute once with the data of the first item.',
description: 'If active, the node executes only once, with data<br /> from the first item it recieves. ',
},
{
displayName: 'Retry On Fail',
@ -202,7 +202,7 @@ export default mixins(
type: 'boolean',
default: false,
noDataExpression: true,
description: 'If activated it will automatically retry the node again multiple times.',
description: 'If active, the node tries to execute a failed attempt <br /> multiple times until it succeeds.',
},
{
displayName: 'Max. Tries',
@ -221,7 +221,7 @@ export default mixins(
},
},
noDataExpression: true,
description: 'How often it should try to execute the node before it should fail.',
description: 'Number of times Retry On Fail should attempt to execute the node <br />before stopping and returning the execution as failed.',
},
{
displayName: 'Wait Between Tries',
@ -240,7 +240,7 @@ export default mixins(
},
},
noDataExpression: true,
description: 'How long to wait between ties. Value in ms.',
description: 'How long to wait between each attempt. Value in ms.',
},
{
displayName: 'Continue On Fail',
@ -248,7 +248,7 @@ export default mixins(
type: 'boolean',
default: false,
noDataExpression: true,
description: 'If activated and the node fails the workflow will simply continue running.<br />It will then simply pass through the input data so the workflow has<br />to be set up to handle the case that different data gets returned.',
description: 'If active, the workflow continues even if this node\'s <br /execution fails. When this occurs, the node passes along input data from<br />previous nodes - so your workflow should account for unexpected output data.',
},
] as INodeProperties[],

View file

@ -88,7 +88,7 @@ export default mixins(
this.$showMessage({
title: 'Copied',
message: `The webhook URL got copied!`,
message: `The webhook URL was successfully copied!`,
type: 'success',
});
},

View file

@ -7,7 +7,7 @@
:disabled="workflowRunning"
@click.stop="runWorkflow(node.name)"
class="execute-node-button"
:title="`Executes node ${node.name} and all not already executed nodes before it.`"
:title="`Executes this ${node.name} node after executing any previous nodes that have not yet returned data`"
>
<div class="run-icon-button">
<font-awesome-icon v-if="!workflowRunning" icon="play-circle"/>
@ -72,14 +72,14 @@
<span v-else>
<div v-if="showData === false" class="to-much-data">
<h3>
Node contains large amount of data
Node returned a large amount of data
</h3>
<div class="text">
The node contains {{parseInt(dataSize/1024).toLocaleString()}} KB of data.<br />
Displaying it could cause problems!<br />
<br />
If you decide to display it anyway avoid the JSON view!
If you do decide to display it, avoid the JSON view!
</div>
<el-button size="small" @click="displayMode = 'Table';showData = true;">
@ -162,7 +162,7 @@
<div>
<strong>No data</strong><br />
<br />
To display data execute the node first by pressing the execute button above.
Data returned by this node will display here<br />
</div>
</div>
</div>

View file

@ -34,6 +34,7 @@ import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { restApi } from '@/components/mixins/restApi';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { IWorkflowShortResponse } from '@/Interface';
import mixins from 'vue-typed-mixins';
@ -42,6 +43,7 @@ export default mixins(
genericHelpers,
restApi,
showMessage,
titleChange,
).extend({
name: 'WorkflowOpen',
props: [
@ -89,6 +91,7 @@ export default mixins(
},
openWorkflow (data: IWorkflowShortResponse, column: any) { // tslint:disable-line:no-any
if (column.label !== 'Active') {
this.$titleSet(data.name, 'IDLE');
this.$emit('openWorkflow', data.id);
}
},

View file

@ -10,12 +10,14 @@ import {
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import mixins from 'vue-typed-mixins';
export const pushConnection = mixins(
nodeHelpers,
showMessage,
titleChange,
)
.extend({
data () {
@ -147,7 +149,6 @@ export const pushConnection = mixins(
*/
pushMessageReceived (event: Event, isRetry?: boolean): boolean {
const retryAttempts = 5;
let receivedData: IPushData;
try {
// @ts-ignore
@ -201,13 +202,15 @@ export const pushConnection = mixins(
const runDataExecuted = pushData.data;
// @ts-ignore
const workflow = this.getWorkflow();
if (runDataExecuted.finished !== true) {
// There was a problem with executing the workflow
let errorMessage = 'There was a problem executing the workflow!';
if (runDataExecuted.data.resultData.error && runDataExecuted.data.resultData.error.message) {
errorMessage = `There was a problem executing the workflow:<br /><strong>"${runDataExecuted.data.resultData.error.message}"</strong>`;
}
this.$titleSet(workflow.name, 'ERROR');
this.$showMessage({
title: 'Problem executing workflow',
message: errorMessage,
@ -215,6 +218,7 @@ export const pushConnection = mixins(
});
} else {
// Workflow did execute without a problem
this.$titleSet(workflow.name, 'IDLE');
this.$showMessage({
title: 'Workflow got executed',
message: 'Workflow did get executed successfully!',

View file

@ -0,0 +1,31 @@
import Vue from 'vue';
import {
WorkflowTitleStatus,
} from '../../Interface';
export const titleChange = Vue.extend({
methods: {
/**
* Change title of n8n tab
*
* @param {string} workflow Name of workflow
* @param {WorkflowTitleStatus} status Status of workflow
*/
$titleSet(workflow: string, status: WorkflowTitleStatus) {
let icon = '⚠️';
if (status === 'EXECUTING') {
icon = '🔄';
} else if (status === 'IDLE') {
icon = '▶️';
}
window.document.title = `n8n - ${icon} ${workflow}`;
},
$titleReset() {
document.title = `n8n - Workflow Automation`;
},
},
});

View file

@ -14,10 +14,12 @@ import { restApi } from '@/components/mixins/restApi';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import { titleChange } from './titleChange';
export const workflowRun = mixins(
restApi,
workflowHelpers,
titleChange,
).extend({
methods: {
// Starts to executes a workflow on server.
@ -27,6 +29,7 @@ export const workflowRun = mixins(
// because then it can not receive the data as it executes.
throw new Error('No active connection to server. It is maybe down.');
}
const workflow = this.getWorkflow();
this.$store.commit('addActiveAction', 'workflowRunning');
@ -55,6 +58,7 @@ export const workflowRun = mixins(
}
const workflow = this.getWorkflow();
this.$titleSet(workflow.name as string, 'EXECUTING');
try {
// Check first if the workflow has any issues before execute it
@ -78,6 +82,7 @@ export const workflowRun = mixins(
type: 'error',
duration: 0,
});
this.$titleSet(workflow.name as string, 'ERROR');
return;
}
}
@ -165,8 +170,9 @@ export const workflowRun = mixins(
};
this.$store.commit('setWorkflowExecutionData', executionData);
return await this.runWorkflowApi(startRunData);
return await this.runWorkflowApi(startRunData);
} catch (error) {
this.$titleSet(workflow.name as string, 'ERROR');
this.$showError(error, 'Problem running workflow', 'There was a problem running the workflow:');
return undefined;
}

View file

@ -115,6 +115,8 @@ import { mouseSelect } from '@/components/mixins/mouseSelect';
import { moveNodeWorkflow } from '@/components/mixins/moveNodeWorkflow';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowRun } from '@/components/mixins/workflowRun';
@ -165,6 +167,7 @@ export default mixins(
moveNodeWorkflow,
restApi,
showMessage,
titleChange,
workflowHelpers,
workflowRun,
)
@ -1324,6 +1327,8 @@ export default mixins(
}
if (workflowId !== null) {
const workflow = await this.restApi().getWorkflow(workflowId);
this.$titleSet(workflow.name, 'IDLE');
// Open existing workflow
await this.openWorkflow(workflowId);
} else {

View file

@ -0,0 +1,21 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class ConvertKitApi implements ICredentialType {
name = 'convertKitApi';
displayName = 'ConvertKit API';
properties = [
{
displayName: 'API Secret',
name: 'apiSecret',
type: 'string' as NodePropertyTypes,
default: '',
typeOptions: {
password: true,
},
},
];
}

View file

@ -19,6 +19,7 @@ export class GmailOAuth2Api implements ICredentialType {
'googleOAuth2Api',
];
displayName = 'Gmail OAuth2 API';
documentationUrl = 'google';
properties = [
{
displayName: 'Scope',

View file

@ -39,7 +39,7 @@ export class Postgres implements ICredentialType {
{
displayName: 'Ignore SSL Issues',
name: 'allowUnauthorizedCerts',
type: 'boolean',
type: 'boolean' as NodePropertyTypes,
default: false,
description: 'Connect even if SSL certificate validation is not possible.',
},

View file

@ -0,0 +1,46 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class TodoistOAuth2Api implements ICredentialType {
name = 'todoistOAuth2Api';
extends = [
'oAuth2Api',
];
displayName = 'Todoist OAuth2 API';
properties = [
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://todoist.com/oauth/authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://todoist.com/oauth/access_token',
required: true,
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden' as NodePropertyTypes,
default: 'data:read_write,data:delete',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden' as NodePropertyTypes,
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden' as NodePropertyTypes,
default: 'body',
},
];
}

View file

@ -13,7 +13,8 @@ export class ZendeskApi implements ICredentialType {
name: 'subdomain',
type: 'string' as NodePropertyTypes,
description: 'The subdomain of your Zendesk work environment.',
default: 'n8n',
placeholder: 'company',
default: '',
},
{
displayName: 'Email',

View file

@ -237,10 +237,6 @@ export class Coda implements INodeType {
const options = this.getNodeParameter('options', i) as IDataObject;
const endpoint = `/docs/${docId}/tables/${tableId}/rows`;
if (options.keyColumns) {
// @ts-ignore
items[i].json['keyColumns'] = options.keyColumns.split(',') as string[];
}
if (options.disableParsing) {
qs.disableParsing = options.disableParsing as boolean;
}
@ -264,6 +260,11 @@ export class Coda implements INodeType {
};
}
((sendData[endpoint]! as IDataObject).rows! as IDataObject[]).push({ cells });
if (options.keyColumns) {
// @ts-ignore
(sendData[endpoint]! as IDataObject).keyColumns! = options.keyColumns.split(',') as string[];
}
}
// Now that all data got collected make all the requests

View file

@ -0,0 +1,486 @@
import {
IExecuteFunctions,
ILoadOptionsFunctions,
} from 'n8n-core';
import {
IDataObject,
INodeExecutionData,
INodePropertyOptions,
INodeTypeDescription,
INodeType,
} from 'n8n-workflow';
import {
convertKitApiRequest,
} from './GenericFunctions';
import {
customFieldFields,
customFieldOperations,
} from './CustomFieldDescription';
import {
formFields,
formOperations,
} from './FormDescription';
import {
sequenceFields,
sequenceOperations,
} from './SequenceDescription';
import {
tagFields,
tagOperations,
} from './TagDescription';
import {
tagSubscriberFields,
tagSubscriberOperations,
} from './TagSubscriberDescription';
export class ConvertKit implements INodeType {
description: INodeTypeDescription = {
displayName: 'ConvertKit',
name: 'convertKit',
icon: 'file:convertKit.png',
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume ConvertKit API.',
defaults: {
name: 'ConvertKit',
color: '#fb6970',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'convertKitApi',
required: true,
}
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'Custom Field',
value: 'customField',
},
{
name: 'Form',
value: 'form',
},
{
name: 'Sequence',
value: 'sequence',
},
{
name: 'Tag',
value: 'tag',
},
{
name: 'Tag Subscriber',
value: 'tagSubscriber',
},
],
default: 'form',
description: 'The resource to operate on.'
},
//--------------------
// Field Description
//--------------------
...customFieldOperations,
...customFieldFields,
//--------------------
// FormDescription
//--------------------
...formOperations,
...formFields,
//--------------------
// Sequence Description
//--------------------
...sequenceOperations,
...sequenceFields,
//--------------------
// Tag Description
//--------------------
...tagOperations,
...tagFields,
//--------------------
// Tag Subscriber Description
//--------------------
...tagSubscriberOperations,
...tagSubscriberFields,
],
};
methods = {
loadOptions: {
// Get all the tags to display them to user so that he can
// select them easily
async getTags(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { tags } = await convertKitApiRequest.call(this, 'GET', '/tags');
for (const tag of tags) {
const tagName = tag.name;
const tagId = tag.id;
returnData.push({
name: tagName,
value: tagId,
});
}
return returnData;
},
// Get all the forms to display them to user so that he can
// select them easily
async getForms(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { forms } = await convertKitApiRequest.call(this, 'GET', '/forms');
for (const form of forms) {
const formName = form.name;
const formId = form.id;
returnData.push({
name: formName,
value: formId,
});
}
return returnData;
},
// Get all the sequences to display them to user so that he can
// select them easily
async getSequences(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { courses } = await convertKitApiRequest.call(this, 'GET', '/sequences');
for (const course of courses) {
const courseName = course.name;
const courseId = course.id;
returnData.push({
name: courseName,
value: courseId,
});
}
return returnData;
},
}
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: IDataObject[] = [];
const qs: IDataObject = {};
let responseData;
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 === 'customField') {
if (operation === 'create') {
const label = this.getNodeParameter('label', i) as string;
responseData = await convertKitApiRequest.call(this, 'POST', '/custom_fields', { label }, qs);
}
if (operation === 'delete') {
const id = this.getNodeParameter('id', i) as string;
responseData = await convertKitApiRequest.call(this, 'DELETE', `/custom_fields/${id}`);
}
if (operation === 'get') {
const id = this.getNodeParameter('id', i) as string;
responseData = await convertKitApiRequest.call(this, 'GET', `/custom_fields/${id}`);
}
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
responseData = await convertKitApiRequest.call(this, 'GET', `/custom_fields`);
responseData = responseData.custom_fields;
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
}
if (operation === 'update') {
const id = this.getNodeParameter('id', i) as string;
const label = this.getNodeParameter('label', i) as string;
responseData = await convertKitApiRequest.call(this, 'PUT', `/custom_fields/${id}`, { label });
responseData = { success: true };
}
}
if (resource === 'form') {
if (operation === 'addSubscriber') {
const email = this.getNodeParameter('email', i) as string;
const formId = this.getNodeParameter('id', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
const body: IDataObject = {
email,
};
if (additionalFields.firstName) {
body.first_name = additionalFields.firstName as string;
}
if (additionalFields.tags) {
body.tags = additionalFields.tags as string[];
}
if (additionalFields.fieldsUi) {
const fieldValues = (additionalFields.fieldsUi as IDataObject).fieldsValues as IDataObject[];
if (fieldValues) {
body.fields = {};
for (const fieldValue of fieldValues) {
//@ts-ignore
body.fields[fieldValue.key] = fieldValue.value;
}
}
}
const { subscription } = await convertKitApiRequest.call(this, 'POST', `/forms/${formId}/subscribe`, body);
responseData = subscription;
}
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
responseData = await convertKitApiRequest.call(this, 'GET', `/forms`);
responseData = responseData.forms;
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
}
if (operation === 'getSubscriptions') {
const formId = this.getNodeParameter('id', i) as string;
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
if (additionalFields.subscriberState) {
qs.subscriber_state = additionalFields.subscriberState as string;
}
responseData = await convertKitApiRequest.call(this, 'GET', `/forms/${formId}/subscriptions`, {}, qs);
responseData = responseData.subscriptions;
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
}
}
if (resource === 'sequence') {
if (operation === 'addSubscriber') {
const email = this.getNodeParameter('email', i) as string;
const sequenceId = this.getNodeParameter('id', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
const body: IDataObject = {
email,
};
if (additionalFields.firstName) {
body.first_name = additionalFields.firstName as string;
}
if (additionalFields.tags) {
body.tags = additionalFields.tags as string[];
}
if (additionalFields.fieldsUi) {
const fieldValues = (additionalFields.fieldsUi as IDataObject).fieldsValues as IDataObject[];
if (fieldValues) {
body.fields = {};
for (const fieldValue of fieldValues) {
//@ts-ignore
body.fields[fieldValue.key] = fieldValue.value;
}
}
}
const { subscription } = await convertKitApiRequest.call(this, 'POST', `/sequences/${sequenceId}/subscribe`, body);
responseData = subscription;
}
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
responseData = await convertKitApiRequest.call(this, 'GET', `/sequences`);
responseData = responseData.courses;
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
}
if (operation === 'getSubscriptions') {
const sequenceId = this.getNodeParameter('id', i) as string;
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
if (additionalFields.subscriberState) {
qs.subscriber_state = additionalFields.subscriberState as string;
}
responseData = await convertKitApiRequest.call(this, 'GET', `/sequences/${sequenceId}/subscriptions`, {}, qs);
responseData = responseData.subscriptions;
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
}
}
if (resource === 'tag') {
if (operation === 'create') {
const names = ((this.getNodeParameter('name', i) as string).split(',') as string[]).map((e) => ({ name: e }));
const body: IDataObject = {
tag: names
};
responseData = await convertKitApiRequest.call(this, 'POST', '/tags', body);
}
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
responseData = await convertKitApiRequest.call(this, 'GET', `/tags`);
responseData = responseData.tags;
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
}
}
if (resource === 'tagSubscriber') {
if (operation === 'add') {
const tagId = this.getNodeParameter('tagId', i) as string;
const email = this.getNodeParameter('email', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
const body: IDataObject = {
email,
};
if (additionalFields.firstName) {
body.first_name = additionalFields.firstName as string;
}
if (additionalFields.fieldsUi) {
const fieldValues = (additionalFields.fieldsUi as IDataObject).fieldsValues as IDataObject[];
if (fieldValues) {
body.fields = {};
for (const fieldValue of fieldValues) {
//@ts-ignore
body.fields[fieldValue.key] = fieldValue.value;
}
}
}
const { subscription } = await convertKitApiRequest.call(this, 'POST', `/tags/${tagId}/subscribe`, body);
responseData = subscription;
}
if (operation === 'getAll') {
const tagId = this.getNodeParameter('tagId', i) as string;
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
responseData = await convertKitApiRequest.call(this, 'GET', `/tags/${tagId}/subscriptions`);
responseData = responseData.subscriptions;
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
}
if (operation === 'delete') {
const tagId = this.getNodeParameter('tagId', i) as string;
const email = this.getNodeParameter('email', i) as string;
responseData = await convertKitApiRequest.call(this, 'POST', `/tags/${tagId}>/unsubscribe`, { email });
}
}
if (Array.isArray(responseData)) {
returnData.push.apply(returnData, responseData as IDataObject[]);
} else if (responseData !== undefined) {
returnData.push(responseData as IDataObject);
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}

View file

@ -0,0 +1,367 @@
import {
IHookFunctions,
IWebhookFunctions,
} from 'n8n-core';
import {
IDataObject,
ILoadOptionsFunctions,
INodePropertyOptions,
INodeTypeDescription,
INodeType,
IWebhookResponseData,
} from 'n8n-workflow';
import {
convertKitApiRequest,
} from './GenericFunctions';
import {
snakeCase,
} from 'change-case';
export class ConvertKitTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'ConvertKit Trigger',
name: 'convertKitTrigger',
icon: 'file:convertKit.png',
subtitle: '={{$parameter["event"]}}',
group: ['trigger'],
version: 1,
description: 'Handle ConvertKit events via webhooks',
defaults: {
name: 'ConvertKit Trigger',
color: '#fb6970',
},
inputs: [],
outputs: ['main'],
credentials: [
{
name: 'convertKitApi',
required: true,
},
],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'Event',
name: 'event',
type: 'options',
required: true,
default: '',
description: 'The events that can trigger the webhook and whether they are enabled.',
options: [
{
name: 'Form Subscribe',
value: 'formSubscribe',
},
{
name: 'Link Click',
value: 'linkClick',
},
{
name: 'Product Purchase',
value: 'productPurchase',
},
{
name: 'Purchase Created',
value: 'purchaseCreate',
},
{
name: 'Sequence Complete',
value: 'courseComplete',
},
{
name: 'Sequence Subscribe',
value: 'courseSubscribe',
},
{
name: 'Subscriber Activated',
value: 'subscriberActivate',
},
{
name: 'Subscriber Unsubscribe',
value: 'subscriberUnsubscribe',
},
{
name: 'Tag Add',
value: 'tagAdd',
},
{
name: 'Tag Remove',
value: 'tagRemove',
},
],
},
{
displayName: 'Form ID',
name: 'formId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getForms',
},
required: true,
default: '',
displayOptions: {
show: {
event: [
'formSubscribe',
],
},
},
},
{
displayName: 'Sequence ID',
name: 'courseId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getSequences',
},
required: true,
default: '',
displayOptions: {
show: {
event: [
'courseSubscribe',
'courseComplete',
],
},
},
},
{
displayName: 'Initiating Link',
name: 'link',
type: 'string',
required: true,
default: '',
description: 'The URL of the initiating link',
displayOptions: {
show: {
event: [
'linkClick',
],
},
},
},
{
displayName: 'Product ID',
name: 'productId',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
event: [
'productPurchase',
],
},
},
},
{
displayName: 'Tag ID',
name: 'tagId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getTags',
},
required: true,
default: '',
displayOptions: {
show: {
event: [
'tagAdd',
'tagRemove',
],
},
},
},
],
};
methods = {
loadOptions: {
// Get all the tags to display them to user so that he can
// select them easily
async getTags(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { tags } = await convertKitApiRequest.call(this, 'GET', '/tags');
for (const tag of tags) {
const tagName = tag.name;
const tagId = tag.id;
returnData.push({
name: tagName,
value: tagId,
});
}
return returnData;
},
// Get all the forms to display them to user so that he can
// select them easily
async getForms(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { forms } = await convertKitApiRequest.call(this, 'GET', '/forms');
for (const form of forms) {
const formName = form.name;
const formId = form.id;
returnData.push({
name: formName,
value: formId,
});
}
return returnData;
},
// Get all the sequences to display them to user so that he can
// select them easily
async getSequences(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { courses } = await convertKitApiRequest.call(this, 'GET', '/sequences');
for (const course of courses) {
const courseName = course.name;
const courseId = course.id;
returnData.push({
name: courseName,
value: courseId,
});
}
return returnData;
},
}
};
// @ts-ignore (because of request)
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
// THe API does not have an endpoint to list all webhooks
if (webhookData.webhookId) {
return true;
}
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default');
let event = this.getNodeParameter('event', 0) as string;
const endpoint = '/automations/hooks';
if (event === 'purchaseCreate') {
event = `purchase.${snakeCase(event)}`;
} else {
event = `subscriber.${snakeCase(event)}`;
}
const body: IDataObject = {
target_url: webhookUrl as string,
event: {
name: event
},
};
if (event === 'subscriber.form_subscribe') {
//@ts-ignore
body.event['form_id'] = this.getNodeParameter('formId', 0);
}
if (event === 'subscriber.course_subscribe' || event === 'subscriber.course_complete') {
//@ts-ignore
body.event['sequence_id'] = this.getNodeParameter('courseId', 0);
}
if (event === 'subscriber.link_click') {
//@ts-ignore
body.event['initiator_value'] = this.getNodeParameter('link', 0);
}
if (event === 'subscriber.product_purchase') {
//@ts-ignore
body.event['product_id'] = this.getNodeParameter('productId', 0);
}
if (event === 'subscriber.tag_add' || event === 'subscriber.tag_remove') {
//@ts-ignore
body.event['tag_id'] = this.getNodeParameter('tagId', 0);
}
const webhook = await convertKitApiRequest.call(this, 'POST', endpoint, body);
if (webhook.rule.id === undefined) {
return false;
}
const webhookData = this.getWorkflowStaticData('node');
webhookData.webhookId = webhook.rule.id as string;
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId !== undefined) {
const endpoint = `/automations/hooks/${webhookData.webhookId}`;
try {
await convertKitApiRequest.call(this, 'DELETE', endpoint);
} catch (error) {
return false;
}
delete webhookData.webhookId;
}
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const returnData: IDataObject[] = [];
returnData.push(this.getBodyData());
return {
workflowData: [
this.helpers.returnJsonArray(returnData),
],
};
}
}

View file

@ -0,0 +1,124 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const customFieldOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'customField',
],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a field',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a field',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all fields',
},
{
name: 'Update',
value: 'update',
description: 'Update a field',
},
],
default: 'update',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const customFieldFields = [
{
displayName: 'Field ID',
name: 'id',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'customField',
],
operation: [
'update',
'delete',
],
},
},
default: '',
description: 'The ID of your custom field.',
},
{
displayName: 'Label',
name: 'label',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'customField',
],
operation: [
'update',
'create',
],
},
},
default: '',
description: 'The label of the custom field.',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'customField',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'customField',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'How many results to return.',
},
] as INodeProperties[];

View file

@ -0,0 +1,220 @@
import {
INodeProperties
} from 'n8n-workflow';
export const formOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'form',
],
},
},
options: [
{
name: 'Add Subscriber',
value: 'addSubscriber',
description: 'Add a subscriber',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all forms',
},
{
name: 'Get Subscriptions',
value: 'getSubscriptions',
description: 'List subscriptions to a form including subscriber data',
},
],
default: 'addSubscriber',
description: 'The operations to perform.',
},
] as INodeProperties[];
export const formFields = [
{
displayName: 'Form ID',
name: 'id',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getForms',
},
required: true,
displayOptions: {
show: {
resource: [
'form',
],
operation: [
'addSubscriber',
'getSubscriptions',
],
},
},
default: '',
description: 'Form ID.',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'form',
],
operation: [
'addSubscriber',
],
},
},
default: '',
description: `The subscriber's email address.`,
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'form',
],
operation: [
'addSubscriber',
],
},
},
options: [
{
displayName: 'Custom Fields',
name: 'fieldsUi',
placeholder: 'Add Custom Field',
description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'fieldsValues',
displayName: 'Custom Field',
values: [
{
displayName: 'Field Key',
name: 'key',
type: 'string',
default: '',
placeholder: 'last_name',
description: `The field's key.`,
},
{
displayName: 'Field Value',
name: 'value',
type: 'string',
default: '',
placeholder: 'Doe',
description: 'Value of the field.',
},
],
},
],
},
{
displayName: 'First Name',
name: 'firstName',
type: 'string',
default: '',
description: `The subscriber's first name.`,
},
],
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
'getSubscriptions',
],
resource: [
'form',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
'getSubscriptions',
],
resource: [
'form',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'How many results to return.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'form',
],
operation: [
'getSubscriptions',
],
},
},
options: [
{
displayName: 'Subscriber State',
name: 'subscriberState',
type: 'options',
options: [
{
name: 'Active',
value: 'active',
},
{
name: 'Cancelled',
value: 'cancelled',
},
],
default: 'active',
},
],
description: 'Receive only active subscribers or cancelled subscribers.',
},
] as INodeProperties[];

View file

@ -0,0 +1,67 @@
import {
OptionsWithUri
} from 'request';
import {
IExecuteFunctions,
IExecuteSingleFunctions,
ILoadOptionsFunctions,
} from 'n8n-core';
import {
IDataObject,
IHookFunctions
} from 'n8n-workflow';
export async function convertKitApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions,
method: string, endpoint: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const credentials = this.getCredentials('convertKitApi');
if (credentials === undefined) {
throw new Error('No credentials got returned!');
}
let options: OptionsWithUri = {
headers: {
'Content-Type': 'application/json',
},
method,
qs,
body,
uri: uri || `https://api.convertkit.com/v3${endpoint}`,
json: true,
};
options = Object.assign({}, options, option);
if (Object.keys(options.body).length === 0) {
delete options.body;
}
// it's a webhook so include the api secret on the body
if ((options.uri as string).includes('/automations/hooks')) {
options.body['api_secret'] = credentials.apiSecret;
} else {
qs.api_secret = credentials.apiSecret;
}
if (Object.keys(options.qs).length === 0) {
delete options.qs;
}
try {
return await this.helpers.request!(options);
} catch (error) {
let errorMessage = error;
if (error.response && error.response.body && error.response.body.message) {
errorMessage = error.response.body.message;
}
throw new Error(`ConvertKit error response: ${errorMessage}`);
}
}

View file

@ -0,0 +1,230 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const sequenceOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'sequence',
],
},
},
options: [
{
name: 'Add Subscriber',
value: 'addSubscriber',
description: 'Add a subscriber',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all sequences',
},
{
name: 'Get Subscriptions',
value: 'getSubscriptions',
description: 'Get all subscriptions to a sequence including subscriber data',
},
],
default: 'addSubscriber',
description: 'The operations to perform.',
},
] as INodeProperties[];
export const sequenceFields = [
{
displayName: 'Sequence ID',
name: 'id',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getSequences',
},
required: true,
displayOptions: {
show: {
resource: [
'sequence',
],
operation: [
'addSubscriber',
'getSubscriptions',
],
},
},
default: '',
description: 'Sequence ID.',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'sequence',
],
operation: [
'addSubscriber',
],
},
},
default: '',
description: `The subscriber's email address.`,
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
'getSubscriptions',
],
resource: [
'sequence',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
'getSubscriptions',
],
resource: [
'sequence',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'How many results to return.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'sequence',
],
operation: [
'addSubscriber',
],
},
},
options: [
{
displayName: 'Custom Fields',
name: 'fieldsUi',
placeholder: 'Add Custom Field',
description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'fieldsValues',
displayName: 'Custom Field',
values: [
{
displayName: 'Field Key',
name: 'key',
type: 'string',
default: '',
placeholder: 'last_name',
description: `The field's key.`,
},
{
displayName: 'Field Value',
name: 'value',
type: 'string',
default: '',
placeholder: 'Doe',
description: 'Value of the field.',
},
],
},
],
},
{
displayName: 'First Name',
name: 'firstName',
type: 'string',
default: '',
description: `The subscriber's first name.`,
},
{
displayName: 'Tag IDs',
name: 'tags',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getTags',
},
default: [],
description: 'Tags',
},
],
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'sequence',
],
operation: [
'getSubscriptions',
],
},
},
options: [
{
displayName: 'Subscriber State',
name: 'subscriberState',
type: 'options',
options: [
{
name: 'Active',
value: 'active',
},
{
name: 'Cancelled',
value: 'cancelled',
},
],
default: 'active',
},
],
description: 'Receive only active subscribers or cancelled subscribers.',
},
] as INodeProperties[];

View file

@ -0,0 +1,94 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const tagOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'tag',
],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a tag',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all tags',
},
],
default: 'create',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const tagFields = [
{
displayName: 'Name',
name: 'name',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'tag',
],
operation: [
'create',
],
},
},
default: '',
description: 'Tag name, multiple can be added separated by comma',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'tag',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'tag',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'How many results to return.',
},
] as INodeProperties[];

View file

@ -0,0 +1,219 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const tagSubscriberOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'tagSubscriber',
],
},
},
options: [
{
name: 'Add',
value: 'add',
description: 'Add a tag to a subscriber',
},
{
name: 'Get All',
value: 'getAll',
description: 'List subscriptions to a tag including subscriber data',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a tag from a subscriber',
},
],
default: 'create',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const tagSubscriberFields = [
{
displayName: 'Tag ID',
name: 'tagId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getTags',
},
required: true,
displayOptions: {
show: {
resource: [
'tagSubscriber',
],
operation: [
'add',
'getAll',
'delete',
],
},
},
default: '',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'tagSubscriber',
],
operation: [
'add',
'delete',
],
},
},
default: '',
description: 'Subscriber email address.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'tagSubscriber',
],
operation: [
'add',
],
},
},
options: [
{
displayName: 'Custom Fields',
name: 'fields',
placeholder: 'Add Custom Field',
description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'field',
displayName: 'Custom Field',
values: [
{
displayName: 'Field Key',
name: 'key',
type: 'string',
default: '',
placeholder: 'last_name',
description: `The field's key.`,
},
{
displayName: 'Field Value',
name: 'value',
type: 'string',
default: '',
placeholder: 'Doe',
description: 'Value of the field.',
},
],
},
],
},
{
displayName: 'First Name',
name: 'firstName',
type: 'string',
default: '',
description: 'Subscriber first name.',
},
],
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'tagSubscriber',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'tagSubscriber',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'How many results to return.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'tagSubscriber',
],
operation: [
'getAll',
],
},
},
options: [
{
displayName: 'Subscriber State',
name: 'subscriberState',
type: 'options',
options: [
{
name: 'Active',
value: 'active',
},
{
name: 'Cancelled',
value: 'cancelled',
},
],
default: 'active',
},
],
description: 'Receive only active subscribers or cancelled subscribers.',
},
] as INodeProperties[];

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -1,6 +1,7 @@
import { ITriggerFunctions } from 'n8n-core';
import {
IBinaryData,
IBinaryKeyData,
IDataObject,
INodeExecutionData,
INodeType,
@ -8,7 +9,13 @@ import {
ITriggerResponse,
} from 'n8n-workflow';
import { connect as imapConnect, ImapSimple, ImapSimpleOptions, getParts, Message } from 'imap-simple';
import { connect as imapConnect, ImapSimple, ImapSimpleOptions, getParts, Message } from 'imap-simple';
import {
simpleParser,
Source as ParserSource,
} from 'mailparser';
import * as lodash from 'lodash';
export class EmailReadImap implements INodeType {
description: INodeTypeDescription = {
@ -44,11 +51,11 @@ export class EmailReadImap implements INodeType {
options: [
{
name: 'Mark as read',
value: 'read'
value: 'read',
},
{
name: 'Nothing',
value: 'nothing'
value: 'nothing',
},
],
default: 'read',
@ -59,8 +66,39 @@ export class EmailReadImap implements INodeType {
name: 'downloadAttachments',
type: 'boolean',
default: false,
displayOptions: {
show: {
format: [
'simple',
],
},
},
description: 'If attachments of emails should be downloaded.<br />Only set if needed as it increases processing.',
},
{
displayName: 'Format',
name: 'format',
type: 'options',
options: [
{
name: 'RAW',
value: 'raw',
description: 'Returns the full email message data with body content in the raw field as a base64url encoded string; the payload field is not used.'
},
{
name: 'Resolved',
value: 'resolved',
description: 'Returns the full email with all data resolved and attachments saved as binary data.',
},
{
name: 'Simple',
value: 'simple',
description: 'Returns the full email; do not use if you wish to gather inline attachments.',
},
],
default: 'simple',
description: 'The format to return the message in',
},
{
displayName: 'Property Prefix Name',
name: 'dataPropertyAttachmentsPrefixName',
@ -68,8 +106,25 @@ export class EmailReadImap implements INodeType {
default: 'attachment_',
displayOptions: {
show: {
format: [
'resolved',
],
},
},
description: 'Prefix for name of the binary property to which to<br />write the attachments. An index starting with 0 will be added.<br />So if name is "attachment_" the first attachment is saved to "attachment_0"',
},
{
displayName: 'Property Prefix Name',
name: 'dataPropertyAttachmentsPrefixName',
type: 'string',
default: 'attachment_',
displayOptions: {
show: {
format: [
'simple',
],
downloadAttachments: [
true
true,
],
},
},
@ -105,7 +160,6 @@ export class EmailReadImap implements INodeType {
const mailbox = this.getNodeParameter('mailbox') as string;
const postProcessAction = this.getNodeParameter('postProcessAction') as string;
const downloadAttachments = this.getNodeParameter('downloadAttachments') as boolean;
const options = this.getNodeParameter('options', {}) as IDataObject;
@ -156,16 +210,26 @@ export class EmailReadImap implements INodeType {
// Returns all the new unseen messages
const getNewEmails = async (connection: ImapSimple): Promise<INodeExecutionData[]> => {
const format = this.getNodeParameter('format', 0) as string;
const searchCriteria = [
'UNSEEN'
];
const fetchOptions = {
bodies: ['HEADER', 'TEXT'],
markSeen: postProcessAction === 'read',
struct: true,
};
let fetchOptions = {};
if (format === 'simple' || format === 'raw') {
fetchOptions = {
bodies: ['TEXT', 'HEADER'],
markSeen: postProcessAction === 'read',
struct: true,
};
} else if (format === 'resolved') {
fetchOptions = {
bodies: [''],
markSeen: postProcessAction === 'read',
struct: true,
};
}
const results = await connection.search(searchCriteria, fetchOptions);
@ -174,10 +238,7 @@ export class EmailReadImap implements INodeType {
let attachments: IBinaryData[];
let propertyName: string;
let dataPropertyAttachmentsPrefixName = '';
if (downloadAttachments === true) {
dataPropertyAttachmentsPrefixName = this.getNodeParameter('dataPropertyAttachmentsPrefixName') as string;
}
// All properties get by default moved to metadata except the ones
// which are defined here which get set on the top level.
@ -188,45 +249,83 @@ export class EmailReadImap implements INodeType {
'subject',
'to',
];
for (const message of results) {
const parts = getParts(message.attributes.struct!);
if (format === 'resolved') {
const dataPropertyAttachmentsPrefixName = this.getNodeParameter('dataPropertyAttachmentsPrefixName') as string;
newEmail = {
json: {
textHtml: await getText(parts, message, 'html'),
textPlain: await getText(parts, message, 'plain'),
metadata: {} as IDataObject,
for (const message of results) {
const part = lodash.find(message.parts, { which: '' });
if (part === undefined) {
throw new Error('Email part could not be parsed.');
}
};
const parsedEmail = await parseRawEmail.call(this, part.body, dataPropertyAttachmentsPrefixName);
messageHeader = message.parts.filter((part) => {
return part.which === 'HEADER';
});
messageBody = messageHeader[0].body;
for (propertyName of Object.keys(messageBody)) {
if (messageBody[propertyName].length) {
if (topLevelProperties.includes(propertyName)) {
newEmail.json[propertyName] = messageBody[propertyName][0];
} else {
(newEmail.json.metadata as IDataObject)[propertyName] = messageBody[propertyName][0];
}
}
newEmails.push(parsedEmail);
}
} else if (format === 'simple') {
const downloadAttachments = this.getNodeParameter('downloadAttachments') as boolean;
let dataPropertyAttachmentsPrefixName = '';
if (downloadAttachments === true) {
// Get attachments and add them if any get found
attachments = await getAttachment(connection, parts, message);
if (attachments.length) {
newEmail.binary = {};
for (let i = 0; i < attachments.length; i++) {
newEmail.binary[`${dataPropertyAttachmentsPrefixName}${i}`] = attachments[i];
}
}
dataPropertyAttachmentsPrefixName = this.getNodeParameter('dataPropertyAttachmentsPrefixName') as string;
}
newEmails.push(newEmail);
for (const message of results) {
const parts = getParts(message.attributes.struct!);
newEmail = {
json: {
textHtml: await getText(parts, message, 'html'),
textPlain: await getText(parts, message, 'plain'),
metadata: {} as IDataObject,
}
};
messageHeader = message.parts.filter((part) => {
return part.which === 'HEADER';
});
messageBody = messageHeader[0].body;
for (propertyName of Object.keys(messageBody)) {
if (messageBody[propertyName].length) {
if (topLevelProperties.includes(propertyName)) {
newEmail.json[propertyName] = messageBody[propertyName][0];
} else {
(newEmail.json.metadata as IDataObject)[propertyName] = messageBody[propertyName][0];
}
}
}
if (downloadAttachments === true) {
// Get attachments and add them if any get found
attachments = await getAttachment(connection, parts, message);
if (attachments.length) {
newEmail.binary = {};
for (let i = 0; i < attachments.length; i++) {
newEmail.binary[`${dataPropertyAttachmentsPrefixName}${i}`] = attachments[i];
}
}
}
newEmails.push(newEmail);
}
} else if (format === 'raw') {
for (const message of results) {
const part = lodash.find(message.parts, { which: 'TEXT' });
if (part === undefined) {
throw new Error('Email part could not be parsed.');
}
// Return base64 string
newEmail = {
json: {
raw: part.body
}
};
newEmails.push(newEmail);
}
}
return newEmails;
@ -277,3 +376,32 @@ export class EmailReadImap implements INodeType {
}
}
export async function parseRawEmail(this: ITriggerFunctions, messageEncoded: ParserSource, dataPropertyNameDownload: string): Promise<INodeExecutionData> {
const responseData = await simpleParser(messageEncoded);
const headers: IDataObject = {};
for (const header of responseData.headerLines) {
headers[header.key] = header.line;
}
// @ts-ignore
responseData.headers = headers;
// @ts-ignore
responseData.headerLines = undefined;
const binaryData: IBinaryKeyData = {};
if (responseData.attachments) {
for (let i = 0; i < responseData.attachments.length; i++) {
const attachment = responseData.attachments[i];
binaryData[`${dataPropertyNameDownload}${i}`] = await this.helpers.prepareBinaryData(attachment.content, attachment.filename, attachment.contentType);
}
// @ts-ignore
responseData.attachments = undefined;
}
return {
json: responseData as unknown as IDataObject,
binary: Object.keys(binaryData).length ? binaryData : undefined,
} as INodeExecutionData;
}

View file

@ -821,7 +821,7 @@ export class HttpRequest implements INodeType {
}
else if (oAuth2Api !== undefined) {
//@ts-ignore
response = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions, 'Bearer');
response = await this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions, { tokenType: 'Bearer' });
} else {
response = await this.helpers.request(requestOptions);
}

View file

@ -171,6 +171,41 @@ export const contactFields = [
type: 'string',
default: '',
},
{
displayName: 'Custom Properties',
name: 'customPropertiesUi',
placeholder: 'Add Custom Property',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'customPropertiesValues',
displayName: 'Custom Property',
values: [
{
displayName: 'Property',
name: 'property',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getContactCustomProperties',
},
default: '',
description: 'Name of the property.',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the property',
},
],
},
],
},
{
displayName: 'Date of Birth',
name: 'dateOfBirth',

View file

@ -307,6 +307,25 @@ export class Hubspot implements INodeType {
return returnData;
},
// Get all the contact properties to display them to user so that he can
// select them easily
async getContactCustomProperties(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const endpoint = '/properties/v2/contacts/properties';
const properties = await hubspotApiRequest.call(this, 'GET', endpoint, {});
for (const property of properties) {
if (property.hubspotDefined === null) {
const propertyName = property.label;
const propertyId = property.name;
returnData.push({
name: propertyName,
value: propertyId,
});
}
}
return returnData;
},
// Get all the contact number of employees options to display them to user so that he can
// select them easily
async getContactNumberOfEmployees(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
@ -1064,6 +1083,20 @@ export class Hubspot implements INodeType {
value: additionalFields.workEmail,
});
}
if (additionalFields.customPropertiesUi) {
const customProperties = (additionalFields.customPropertiesUi as IDataObject).customPropertiesValues as IDataObject[];
if (customProperties) {
for (const customProperty of customProperties) {
body.push({
property: customProperty.property,
value: customProperty.value,
});
}
}
}
const endpoint = `/contacts/v1/contact/createOrUpdate/email/${email}`;
responseData = await hubspotApiRequest.call(this, 'POST', endpoint, { properties: body });

View file

@ -10,10 +10,10 @@ import {
export async function salesforceApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const credentials = this.getCredentials('salesforceOAuth2Api');
const subdomain = (credentials!.accessTokenUrl as string).split('.')[0].split('/')[2];
const subdomain = ((credentials!.accessTokenUrl as string).match(/https:\/\/(.+).salesforce\.com/) || [])[1]
const options: OptionsWithUri = {
method,
body,
body: method === "GET" ? undefined : body,
qs,
uri: uri || `https://${subdomain}.salesforce.com/services/data/v39.0${resource}`,
json: true

File diff suppressed because it is too large Load diff

View file

@ -1,66 +1,37 @@
import { OptionsWithUri } from 'request';
import {
OptionsWithUri,
} from 'request';
import {
IExecuteFunctions,
IHookFunctions,
ILoadOptionsFunctions,
IExecuteSingleFunctions
} from 'n8n-core';
import * as _ from 'lodash';
export const filterAndExecuteForEachTask = async function(
this: IExecuteSingleFunctions,
taskCallback: (t: any) => any
) {
const expression = this.getNodeParameter('expression') as string;
const projectId = this.getNodeParameter('project') as number;
// Enable regular expressions
const reg = new RegExp(expression);
const tasks = await todoistApiRequest.call(this, '/tasks', 'GET');
const filteredTasks = tasks.filter(
// Make sure that project will match no matter what the type is. If project was not selected match all projects
(el: any) => (!projectId || el.project_id) && el.content.match(reg)
);
return {
affectedTasks: (
await Promise.all(filteredTasks.map((t: any) => taskCallback(t)))
)
// This makes it more clear and informative. We pass the ID as a convention and content to give the user confirmation that his/her expression works as expected
.map(
(el, i) =>
el || { id: filteredTasks[i].id, content: filteredTasks[i].content }
)
};
};
import {
IDataObject,
} from 'n8n-workflow';
export async function todoistApiRequest(
this:
| IHookFunctions
| IExecuteFunctions
| IExecuteSingleFunctions
| ILoadOptionsFunctions,
resource: string,
method: string,
body: any = {},
headers?: object
): Promise<any> {
// tslint:disable-line:no-any
const credentials = this.getCredentials('todoistApi');
if (credentials === undefined) {
throw new Error('No credentials got returned!');
}
const headerWithAuthentication = Object.assign({}, headers, { Authorization: `Bearer ${credentials.apiKey}` });
resource: string,
body: any = {}, // tslint:disable-line:no-any
qs: IDataObject = {},
): Promise<any> { // tslint:disable-line:no-any
const authentication = this.getNodeParameter('authentication', 0, 'apiKey');
const endpoint = 'api.todoist.com/rest/v1';
const options: OptionsWithUri = {
headers: headerWithAuthentication,
headers: {},
method,
qs,
uri: `https://${endpoint}${resource}`,
json: true
json: true,
};
if (Object.keys(body).length !== 0) {
@ -68,13 +39,25 @@ export async function todoistApiRequest(
}
try {
return this.helpers.request!(options);
if (authentication === 'apiKey') {
const credentials = this.getCredentials('todoistApi') as IDataObject;
//@ts-ignore
options.headers['Authorization'] = `Bearer ${credentials.apiKey}`;
return this.helpers.request!(options);
} else {
//@ts-ignore
return await this.helpers.requestOAuth2.call(this, 'todoistOAuth2Api', options);
}
} catch (error) {
const errorMessage = error.response.body.message || error.response.body.Message;
const errorMessage = error.response.body;
if (errorMessage !== undefined) {
throw errorMessage;
throw new Error(errorMessage);
}
throw error.response.body;
throw errorMessage;
}
}

View file

@ -1,6 +1,7 @@
import {
IExecuteSingleFunctions,
import {
IExecuteFunctions,
} from 'n8n-core';
import {
IDataObject,
INodeTypeDescription,
@ -9,12 +10,11 @@ import {
ILoadOptionsFunctions,
INodePropertyOptions,
} from 'n8n-workflow';
import {
todoistApiRequest,
filterAndExecuteForEachTask,
} from './GenericFunctions';
interface IBodyCreateTask {
content: string;
project_id?: number;
@ -48,9 +48,44 @@ export class Todoist implements INodeType {
{
name: 'todoistApi',
required: true,
}
displayOptions: {
show: {
authentication: [
'apiKey',
],
},
},
},
{
name: 'todoistOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2',
],
},
},
},
],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'API Key',
value: 'apiKey',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'apiKey',
description: 'The resource to operate on.',
},
{
displayName: 'Resource',
name: 'resource',
@ -85,24 +120,29 @@ export class Todoist implements INodeType {
description: 'Create a new task',
},
{
name: 'Close by ID',
value: 'close_id',
description: 'Close a task by passing an ID',
name: 'Close',
value: 'close',
description: 'Close a task',
},
{
name: 'Close matching',
value: 'close_match',
description: 'Close a task by passing a regular expression',
name: 'Delete',
value: 'delete',
description: 'Delete a task',
},
{
name: 'Delete by ID',
value: 'delete_id',
description: 'Delete a task by passing an ID',
name: 'Get',
value: 'get',
description: 'Get a task',
},
{
name: 'Delete matching',
value: 'delete_match',
description: 'Delete a task by passing a regular expression',
name: 'Get All',
value: 'getAll',
description: 'Get all tasks',
},
{
name: 'Reopen',
value: 'reopen',
description: 'Reopen a task',
},
],
default: 'create',
@ -122,9 +162,7 @@ export class Todoist implements INodeType {
],
operation: [
'create',
'close_match',
'delete_match',
]
],
},
},
default: '',
@ -144,7 +182,7 @@ export class Todoist implements INodeType {
],
operation: [
'create',
]
],
},
},
default: [],
@ -165,7 +203,7 @@ export class Todoist implements INodeType {
],
operation: [
'create',
]
],
},
},
default: '',
@ -173,32 +211,27 @@ export class Todoist implements INodeType {
description: 'Task content',
},
{
displayName: 'ID',
name: 'id',
displayName: 'Task ID',
name: 'taskId',
type: 'string',
default: '',
required: true,
typeOptions: { rows: 1 },
displayOptions: {
show: { resource: ['task'], operation: ['close_id', 'delete_id'] }
show: {
resource: [
'task',
],
operation: [
'delete',
'close',
'get',
'reopen',
],
},
},
},
{
displayName: 'Expression to match',
name: 'expression',
type: 'string',
default: '',
required: true,
typeOptions: { rows: 1 },
displayOptions: {
show: {
resource: ['task'],
operation: ['close_match', 'delete_match']
}
}
},
{
displayName: 'Options',
displayName: 'Additional Fields',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
@ -210,22 +243,10 @@ export class Todoist implements INodeType {
],
operation: [
'create',
]
],
},
},
options: [
{
displayName: 'Priority',
name: 'priority',
type: 'number',
typeOptions: {
numberStepSize: 1,
maxValue: 4,
minValue: 1,
},
default: 1,
description: 'Task priority from 1 (normal) to 4 (urgent).',
},
{
displayName: 'Due Date Time',
name: 'dueDateTime',
@ -240,24 +261,131 @@ export class Todoist implements INodeType {
default: '',
description: 'Human defined task due date (ex.: “next Monday”, “Tomorrow”). Value is set using local (not UTC) time.',
},
]
}
]
{
displayName: 'Priority',
name: 'priority',
type: 'number',
typeOptions: {
numberStepSize: 1,
maxValue: 4,
minValue: 1,
},
default: 1,
description: 'Task priority from 1 (normal) to 4 (urgent).',
},
],
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'task',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'task',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'How many results to return.',
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
resource: [
'task',
],
operation: [
'getAll',
],
},
},
options: [
{
displayName: 'Filter',
name: 'filter',
type: 'string',
default: '',
description: 'Filter by any <a href="https://get.todoist.help/hc/en-us/articles/205248842">supported filter.</a>',
},
{
displayName: 'IDs',
name: 'ids',
type: 'string',
default: '',
description: 'A list of the task IDs to retrieve, this should be a comma separated list.',
},
{
displayName: 'Label ID',
name: 'labelId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getLabels',
},
default: {},
description: 'Filter tasks by label.',
},
{
displayName: 'Lang',
name: 'lang',
type: 'string',
default: '',
description: 'IETF language tag defining what language filter is written in, if differs from default English',
},
{
displayName: 'Project ID',
name: 'projectId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getProjects',
},
default: '',
description: 'Filter tasks by project id.',
},
],
},
],
};
methods = {
loadOptions: {
// Get all the available projects to display them to user so that he can
// select them easily
async getProjects(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
let projects;
try {
projects = await todoistApiRequest.call(this, '/projects', 'GET');
} catch (err) {
throw new Error(`Todoist Error: ${err}`);
}
const projects = await todoistApiRequest.call(this, 'GET', '/projects');
for (const project of projects) {
const projectName = project.name;
const projectId = project.id;
@ -275,12 +403,8 @@ export class Todoist implements INodeType {
// select them easily
async getLabels(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
let labels;
try {
labels = await todoistApiRequest.call(this, '/labels', 'GET');
} catch (err) {
throw new Error(`Todoist Error: ${err}`);
}
const labels = await todoistApiRequest.call(this, 'GET', '/labels');
for (const label of labels) {
const labelName = label.name;
const labelId = label.id;
@ -296,67 +420,113 @@ export class Todoist implements INodeType {
}
};
async executeSingle(this: IExecuteSingleFunctions): Promise<INodeExecutionData> {
const resource = this.getNodeParameter('resource') as string;
const operation = this.getNodeParameter('operation') as string;
try {
return {
json: { result: await OPERATIONS[resource]?.[operation]?.bind(this)() }
};
} catch (err) {
return { json: { error: `Todoist Error: ${err.message}` } };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: IDataObject[] = [];
const length = items.length as unknown as number;
const qs: IDataObject = {};
let responseData;
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
for (let i = 0; i < length; i++) {
if (resource === 'task') {
if (operation === 'create') {
//https://developer.todoist.com/rest/v1/#create-a-new-task
const content = this.getNodeParameter('content', i) as string;
const projectId = this.getNodeParameter('project', i) as number;
const labels = this.getNodeParameter('labels', i) as number[];
const options = this.getNodeParameter('options', i) as IDataObject;
const body: IBodyCreateTask = {
content,
project_id: projectId,
priority: (options.priority!) ? parseInt(options.priority as string, 10) : 1,
};
if (options.dueDateTime) {
body.due_datetime = options.dueDateTime as string;
}
if (options.dueString) {
body.due_string = options.dueString as string;
}
if (labels !== undefined && labels.length !== 0) {
body.label_ids = labels;
}
responseData = await todoistApiRequest.call(this, 'POST', '/tasks', body);
}
if (operation === 'close') {
//https://developer.todoist.com/rest/v1/#close-a-task
const id = this.getNodeParameter('taskId', i) as string;
responseData = await todoistApiRequest.call(this, 'POST', `/tasks/${id}/close`);
responseData = { success: true };
}
if (operation === 'delete') {
//https://developer.todoist.com/rest/v1/#delete-a-task
const id = this.getNodeParameter('taskId', i) as string;
responseData = await todoistApiRequest.call(this, 'DELETE', `/tasks/${id}`);
responseData = { success: true };
}
if (operation === 'get') {
//https://developer.todoist.com/rest/v1/#get-an-active-task
const id = this.getNodeParameter('taskId', i) as string;
responseData = await todoistApiRequest.call(this, 'GET', `/tasks/${id}`);
}
if (operation === 'getAll') {
//https://developer.todoist.com/rest/v1/#get-active-tasks
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
const filters = this.getNodeParameter('filters', i) as IDataObject;
if (filters.projectId) {
qs.project_id = filters.projectId as string;
}
if (filters.labelId) {
qs.label_id = filters.labelId as string;
}
if (filters.filter) {
qs.filter = filters.filter as string;
}
if (filters.lang) {
qs.lang = filters.lang as string;
}
if (filters.ids) {
qs.ids = filters.ids as string;
}
responseData = await todoistApiRequest.call(this, 'GET', '/tasks', {}, qs);
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.splice(0, limit);
}
}
if (operation === 'reopen') {
//https://developer.todoist.com/rest/v1/#get-an-active-task
const id = this.getNodeParameter('taskId', i) as string;
responseData = await todoistApiRequest.call(this, 'POST', `/tasks/${id}/reopen`);
responseData = { success: true };
}
}
if (Array.isArray(responseData)) {
returnData.push.apply(returnData, responseData as IDataObject[]);
} else {
returnData.push(responseData as IDataObject);
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}
const OPERATIONS: {
[key: string]: { [key: string]: (this: IExecuteSingleFunctions) => any };
} = {
task: {
create(this: IExecuteSingleFunctions) {
//https://developer.todoist.com/rest/v1/#create-a-new-task
const content = this.getNodeParameter('content') as string;
const projectId = this.getNodeParameter('project') as number;
const labels = this.getNodeParameter('labels') as number[];
const options = this.getNodeParameter('options') as IDataObject;
const body: IBodyCreateTask = {
content,
project_id: projectId,
priority: (options.priority!) ? parseInt(options.priority as string, 10) : 1,
};
if (options.dueDateTime) {
body.due_datetime = options.dueDateTime as string;
}
if (options.dueString) {
body.due_string = options.dueString as string;
}
if (labels !== undefined && labels.length !== 0) {
body.label_ids = labels;
}
return todoistApiRequest.call(this, '/tasks', 'POST', body);
},
close_id(this: IExecuteSingleFunctions) {
const id = this.getNodeParameter('id') as string;
return todoistApiRequest.call(this, `/tasks/${id}/close`, 'POST');
},
delete_id(this: IExecuteSingleFunctions) {
const id = this.getNodeParameter('id') as string;
return todoistApiRequest.call(this, `/tasks/${id}`, 'DELETE');
},
close_match(this) {
return filterAndExecuteForEachTask.call(this, t =>
todoistApiRequest.call(this, `/tasks/${t.id}/close`, 'POST')
);
},
delete_match(this) {
return filterAndExecuteForEachTask.call(this, t =>
todoistApiRequest.call(this, `/tasks/${t.id}`, 'DELETE')
);
}
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -23,7 +23,10 @@ export async function zendeskApiRequest(this: IHookFunctions | IExecuteFunctions
body,
//@ts-ignore
uri,
json: true
json: true,
qsStringifyOptions: {
arrayFormat: 'brackets',
},
};
options = Object.assign({}, options, option);
@ -56,6 +59,7 @@ export async function zendeskApiRequest(this: IHookFunctions | IExecuteFunctions
return await this.helpers.requestOAuth2!.call(this, 'zendeskOAuth2Api', options);
}
} catch(err) {
let errorMessage = err.message;
if (err.response && err.response.body && err.response.body.error) {
errorMessage = err.response.body.error;

View file

@ -0,0 +1,692 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const userOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'user',
],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a user',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a user',
},
{
name: 'Get',
value: 'get',
description: 'Get a user',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all users',
},
{
name: 'Update',
value: 'update',
description: 'Update a user',
},
],
default: 'create',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const userFields = [
/* -------------------------------------------------------------------------- */
/* user:create */
/* -------------------------------------------------------------------------- */
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'create',
],
},
},
required: true,
description: `The user's name`,
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'create',
],
},
},
options: [
{
displayName: 'Alias',
name: 'alis',
type: 'string',
default: '',
description: `An alias displayed to end users`,
},
{
displayName: 'Custom Role ID',
name: 'custom_role_id',
type: 'number',
default: 0,
description: `A custom role if the user is an agent on the Enterprise plan`,
},
{
displayName: 'Details',
name: 'details',
type: 'string',
default: '',
description: 'Any details you want to store about the user, such as an address',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
default: '',
description: `The user's primary email address`,
},
{
displayName: 'External ID',
name: 'externalId',
type: 'string',
default: '',
description: 'A unique identifier from another system',
},
{
displayName: 'Locale ID',
name: 'locale',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getLocales',
},
default: '',
description: `The user's locale.`,
},
{
displayName: 'Moderator',
name: 'moderator',
type: 'boolean',
default: false,
description: 'Designates whether the user has forum moderation capabilities',
},
{
displayName: 'Notes',
name: 'notes',
type: 'string',
default: '',
description: 'Any notes you want to store about the user',
},
{
displayName: 'Only Private Comments',
name: 'only_private_comments',
type: 'boolean',
default: false,
description: `true if the user can only create private comments`,
},
{
displayName: 'Organization ID',
name: 'organizationId',
type: 'number',
default: 0,
description: `The id of the user's organization. If the user has more than one organization memberships, the id of the user's default organization`,
},
{
displayName: 'Phone',
name: 'phone',
type: 'string',
default: '',
description: `The user's primary phone number.`,
},
{
displayName: 'Report CSV',
name: 'report_csv',
type: 'boolean',
default: false,
description: `Whether or not the user can access the CSV report on the Search tab of the Reporting page in the Support admin interface.`,
},
{
displayName: 'Restricted Agent',
name: 'restricted_agent',
type: 'boolean',
default: false,
description: `If the agent has any restrictions; false for admins and unrestricted agents, true for other agents`,
},
{
displayName: 'Role',
name: 'role',
type: 'options',
options: [
{
name: 'End User',
value: 'end-user',
},
{
name: 'Agent',
value: 'agent',
},
{
name: 'Admin',
value: 'admin',
},
],
default: '',
description: `The user's role`,
},
{
displayName: 'Signature',
name: 'signature',
type: 'string',
default: '',
description: `The user's signature. Only agents and admins can have signatures`,
},
{
displayName: 'Suspended',
name: 'suspended',
type: 'boolean',
default: false,
description: `If the agent is suspended. Tickets from suspended users are also suspended, and these users cannot sign in to the end user portal`,
},
{
displayName: 'Tags',
name: 'tags',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getTags',
},
default: [],
description: 'The array of tags applied to this user',
},
{
displayName: 'Ticket Restriction',
name: 'ticket_restriction',
type: 'options',
options: [
{
name: 'Organization',
value: 'organization',
},
{
name: 'Groups',
value: 'groups',
},
{
name: 'Assigned',
value: 'assigned',
},
{
name: 'Requested',
value: 'requested',
},
],
default: '',
description: `Specifies which tickets the user has access to`,
},
{
displayName: 'Timezone',
name: 'time_zone',
type: 'string',
default: '',
description: `The user's time zone.`,
},
{
displayName: 'User Fields',
name: 'userFieldsUi',
placeholder: 'Add User Field',
description: `Values of custom fields in the user's profile.`,
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'userFieldValues',
displayName: 'Field',
values: [
{
displayName: 'Field',
name: 'field',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getUserFields',
},
default: '',
description: 'Name of the field to sort on.',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the field.',
},
],
},
],
},
{
displayName: 'Verified',
name: 'verified',
type: 'boolean',
default: false,
description: `The user's primary identity is verified or not`,
},
],
},
/* -------------------------------------------------------------------------- */
/* user:update */
/* -------------------------------------------------------------------------- */
{
displayName: 'User ID',
name: 'id',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'update',
],
},
},
description: 'User ID',
},
{
displayName: 'Update Fields',
name: 'updateFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'update',
],
},
},
options: [
{
displayName: 'Alias',
name: 'alis',
type: 'string',
default: '',
description: `An alias displayed to end users`,
},
{
displayName: 'Custom Role ID',
name: 'custom_role_id',
type: 'number',
default: 0,
description: `A custom role if the user is an agent on the Enterprise plan`,
},
{
displayName: 'Details',
name: 'details',
type: 'string',
default: '',
description: 'Any details you want to store about the user, such as an address',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
default: '',
description: `The user's primary email address`,
},
{
displayName: 'External ID',
name: 'externalId',
type: 'string',
default: '',
description: 'A unique identifier from another system',
},
{
displayName: 'Locale ID',
name: 'locale',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getLocales',
},
default: '',
description: `The user's locale.`,
},
{
displayName: 'Moderator',
name: 'moderator',
type: 'boolean',
default: false,
description: 'Designates whether the user has forum moderation capabilities',
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: `The user's name`,
},
{
displayName: 'Notes',
name: 'notes',
type: 'string',
default: '',
description: 'Any notes you want to store about the user',
},
{
displayName: 'Only Private Comments',
name: 'only_private_comments',
type: 'boolean',
default: false,
description: `true if the user can only create private comments`,
},
{
displayName: 'Organization ID',
name: 'organizationId',
type: 'number',
default: 0,
description: `The id of the user's organization. If the user has more than one organization memberships, the id of the user's default organization`,
},
{
displayName: 'Phone',
name: 'phone',
type: 'string',
default: '',
description: `The user's primary phone number.`,
},
{
displayName: 'Report CSV',
name: 'report_csv',
type: 'boolean',
default: false,
description: `Whether or not the user can access the CSV report on the Search tab of the Reporting page in the Support admin interface.`,
},
{
displayName: 'Restricted Agent',
name: 'restricted_agent',
type: 'boolean',
default: false,
description: `If the agent has any restrictions; false for admins and unrestricted agents, true for other agents`,
},
{
displayName: 'Role',
name: 'role',
type: 'options',
options: [
{
name: 'End User',
value: 'end-user',
},
{
name: 'Agent',
value: 'agent',
},
{
name: 'Admin',
value: 'admin',
},
],
default: '',
description: `The user's role`,
},
{
displayName: 'Signature',
name: 'signature',
type: 'string',
default: '',
description: `The user's signature. Only agents and admins can have signatures`,
},
{
displayName: 'Suspended',
name: 'suspended',
type: 'boolean',
default: false,
description: `If the agent is suspended. Tickets from suspended users are also suspended, and these users cannot sign in to the end user portal`,
},
{
displayName: 'Tags',
name: 'tags',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getTags',
},
default: [],
description: 'The array of tags applied to this user',
},
{
displayName: 'Ticket Restriction',
name: 'ticket_restriction',
type: 'options',
options: [
{
name: 'Organization',
value: 'organization',
},
{
name: 'Groups',
value: 'groups',
},
{
name: 'Assigned',
value: 'assigned',
},
{
name: 'Requested',
value: 'requested',
},
],
default: '',
description: `Specifies which tickets the user has access to`,
},
{
displayName: 'Timezone',
name: 'time_zone',
type: 'string',
default: '',
description: `The user's time zone.`,
},
{
displayName: 'User Fields',
name: 'userFieldsUi',
placeholder: 'Add User Field',
description: `Values of custom fields in the user's profile.`,
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'userFieldValues',
displayName: 'Field',
values: [
{
displayName: 'Field',
name: 'field',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getUserFields',
},
default: '',
description: 'Name of the field to sort on.',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the field.',
},
],
},
],
},
{
displayName: 'Verified',
name: 'verified',
type: 'boolean',
default: false,
description: `The user's primary identity is verified or not`,
},
],
},
/* -------------------------------------------------------------------------- */
/* user:get */
/* -------------------------------------------------------------------------- */
{
displayName: 'User ID',
name: 'id',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'get',
],
},
},
description: 'User ID',
},
/* -------------------------------------------------------------------------- */
/* user:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'getAll',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'getAll',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 100,
description: 'How many results to return.',
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
placeholder: 'Add Filter',
default: {},
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'getAll',
],
},
},
options: [
{
displayName: 'Roles',
name: 'role',
type: 'multiOptions',
options: [
{
name: 'End User',
value: 'end-user',
},
{
name: 'Agent',
value: 'agent',
},
{
name: 'Admin',
value: 'admin',
},
],
default: [],
},
],
},
/* -------------------------------------------------------------------------- */
/* user:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'User ID',
name: 'id',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: [
'user',
],
operation: [
'delete',
],
},
},
description: 'User ID',
},
] as INodeProperties[];

View file

@ -27,11 +27,15 @@ import {
ticketFieldOperations
} from './TicketFieldDescription';
import {
userFields,
userOperations
} from './UserDescription';
import {
ITicket,
IComment,
} from './TicketInterface';
import { response } from 'express';
export class Zendesk implements INodeType {
description: INodeTypeDescription = {
@ -105,6 +109,11 @@ export class Zendesk implements INodeType {
value: 'ticketField',
description: 'Manage system and custom ticket fields',
},
{
name: 'User',
value: 'user',
description: 'Manage users',
},
],
default: 'ticket',
description: 'Resource to consume.',
@ -112,9 +121,12 @@ export class Zendesk implements INodeType {
// TICKET
...ticketOperations,
...ticketFields,
// TICKET FIELDS
// TICKET FIELD
...ticketFieldOperations,
...ticketFieldFields,
// USER
...userOperations,
...userFields,
],
};
@ -177,6 +189,38 @@ export class Zendesk implements INodeType {
}
return returnData;
},
// Get all the locales to display them to user so that he can
// select them easily
async getLocales(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const locales = await zendeskApiRequestAllItems.call(this, 'locales', 'GET', '/locales');
for (const locale of locales) {
const localeName = `${locale.locale} - ${locale.name}`;
const localeId = locale.locale;
returnData.push({
name: localeName,
value: localeId,
});
}
return returnData;
},
// Get all the user fields to display them to user so that he can
// select them easily
async getUserFields(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const fields = await zendeskApiRequestAllItems.call(this, 'user_fields', 'GET', '/user_fields');
for (const field of fields) {
const fieldName = field.title;
const fieldId = field.key;
returnData.push({
name: fieldName,
value: fieldId,
});
}
return returnData;
},
}
};
@ -359,6 +403,87 @@ export class Zendesk implements INodeType {
}
}
}
//https://developer.zendesk.com/rest_api/docs/support/users
if (resource === 'user') {
//https://developer.zendesk.com/rest_api/docs/support/users#create-user
if (operation === 'create') {
const name = this.getNodeParameter('name', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
const body: IDataObject = {
name,
};
Object.assign(body, additionalFields);
if (body.userFieldsUi) {
const userFields = (body.userFieldsUi as IDataObject).userFieldValues as IDataObject[];
if (userFields) {
body.user_fields = {};
for (const userField of userFields) {
//@ts-ignore
body.user_fields[userField.field] = userField.value;
}
delete body.userFieldsUi;
}
}
responseData = await zendeskApiRequest.call(this, 'POST', '/users', { user: body });
responseData = responseData.user;
}
//https://developer.zendesk.com/rest_api/docs/support/tickets#update-ticket
if (operation === 'update') {
const userId = this.getNodeParameter('id', i) as string;
const updateFields = this.getNodeParameter('updateFields', i) as IDataObject;
const body: IDataObject = {};
Object.assign(body, updateFields);
if (body.userFieldsUi) {
const userFields = (body.userFieldsUi as IDataObject).userFieldValues as IDataObject[];
if (userFields) {
body.user_fields = {};
for (const userField of userFields) {
//@ts-ignore
body.user_fields[userField.field] = userField.value;
}
delete body.userFieldsUi;
}
}
responseData = await zendeskApiRequest.call(this, 'PUT', `/users/${userId}`, { user: body });
responseData = responseData.user;
}
//https://developer.zendesk.com/rest_api/docs/support/users#show-user
if (operation === 'get') {
const userId = this.getNodeParameter('id', i) as string;
responseData = await zendeskApiRequest.call(this, 'GET', `/users/${userId}`, {});
responseData = responseData.user;
}
//https://developer.zendesk.com/rest_api/docs/support/users#list-users
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
const options = this.getNodeParameter('filters', i) as IDataObject;
Object.assign(qs, options);
if (returnAll) {
responseData = await zendeskApiRequestAllItems.call(this, 'users', 'GET', `/users`, {}, qs);
} else {
const limit = this.getNodeParameter('limit', i) as number;
qs.per_page = limit;
responseData = await zendeskApiRequest.call(this, 'GET', `/users`, {}, qs);
responseData = responseData.users;
}
}
//https://developer.zendesk.com/rest_api/docs/support/users#delete-user
if (operation === 'delete') {
const userId = this.getNodeParameter('id', i) as string;
responseData = await zendeskApiRequest.call(this, 'DELETE', `/users/${userId}`, {});
responseData = responseData.user;
}
}
if (Array.isArray(responseData)) {
returnData.push.apply(returnData, responseData as IDataObject[]);
} else {

View file

@ -375,14 +375,14 @@ export class ZendeskTrigger implements INodeType {
displayName: 'All',
values: [
...conditionFields,
]
],
},
{
name: 'any',
displayName: 'Any',
values: [
...conditionFields,
]
],
},
],
},
@ -435,17 +435,74 @@ export class ZendeskTrigger implements INodeType {
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default') as string;
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId === undefined) {
const conditions = this.getNodeParameter('conditions') as IDataObject;
const conditionsAll = conditions.all as [IDataObject];
let endpoint = '';
const aux: IDataObject = {};
const resultAll = [], resultAny = [];
if (conditionsAll) {
for (const conditionAll of conditionsAll) {
aux.field = conditionAll.field;
aux.operator = conditionAll.operation;
if (conditionAll.operation !== 'changed'
&& conditionAll.operation !== 'not_changed') {
aux.value = conditionAll.value;
} else {
aux.value = null;
}
resultAll.push(aux);
}
}
const conditionsAny = conditions.any as [IDataObject];
if (conditionsAny) {
for (const conditionAny of conditionsAny) {
aux.field = conditionAny.field;
aux.operator = conditionAny.operation;
if (conditionAny.operation !== 'changed'
&& conditionAny.operation !== 'not_changed') {
aux.value = conditionAny.value;
} else {
aux.value = null;
}
resultAny.push(aux);
}
}
// check if there is a target already created
endpoint = `/targets`;
const targets = await zendeskApiRequestAllItems.call(this, 'targets', 'GET', endpoint);
for (const target of targets) {
if (target.target_url === webhookUrl) {
webhookData.targetId = target.id.toString();
break;
}
}
// no target was found
if (webhookData.targetId === undefined) {
return false;
}
const endpoint = `/triggers/${webhookData.webhookId}`;
try {
await zendeskApiRequest.call(this, 'GET', endpoint);
} catch (e) {
return false;
endpoint = `/triggers/active`;
const triggers = await zendeskApiRequestAllItems.call(this, 'triggers', 'GET', endpoint);
for (const trigger of triggers) {
const toDeleteTriggers = [];
// this trigger belong to the current target
if (trigger.actions[0].value[0].toString() === webhookData.targetId?.toString()) {
toDeleteTriggers.push(trigger.id);
}
// delete all trigger attach to this target;
if (toDeleteTriggers.length !== 0) {
await zendeskApiRequest.call(this, 'DELETE', '/triggers/destroy_many', {}, { ids: toDeleteTriggers.join(',') } );
}
}
return true;
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default') as string;
@ -509,8 +566,8 @@ export class ZendeskTrigger implements INodeType {
{
field: 'notification_target',
value: [],
}
]
},
],
},
};
const bodyTarget: IDataObject = {
@ -523,9 +580,21 @@ export class ZendeskTrigger implements INodeType {
content_type: 'application/json',
},
};
const { target } = await zendeskApiRequest.call(this, 'POST', '/targets', bodyTarget);
let target: IDataObject = {};
// if target id exists but trigger does not then reuse the target
// and create the trigger else create both
if (webhookData.targetId !== undefined) {
target.id = webhookData.targetId;
} else {
target = await zendeskApiRequest.call(this, 'POST', '/targets', bodyTarget);
target = target.target as IDataObject;
}
// @ts-ignore
bodyTrigger.trigger.actions[0].value = [target.id, JSON.stringify(message)];
//@ts-ignore
const { trigger } = await zendeskApiRequest.call(this, 'POST', '/triggers', bodyTrigger);
webhookData.webhookId = trigger.id;
webhookData.targetId = target.id;

View file

@ -1,6 +1,6 @@
{
"name": "n8n-nodes-base",
"version": "0.73.0",
"version": "0.74.0",
"description": "Base nodes of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -48,6 +48,7 @@
"dist/credentials/CockpitApi.credentials.js",
"dist/credentials/CodaApi.credentials.js",
"dist/credentials/ContentfulApi.credentials.js",
"dist/credentials/ConvertKitApi.credentials.js",
"dist/credentials/CopperApi.credentials.js",
"dist/credentials/CalendlyApi.credentials.js",
"dist/credentials/CustomerIoApi.credentials.js",
@ -150,6 +151,7 @@
"dist/credentials/SurveyMonkeyOAuth2Api.credentials.js",
"dist/credentials/TelegramApi.credentials.js",
"dist/credentials/TodoistApi.credentials.js",
"dist/credentials/TodoistOAuth2Api.credentials.js",
"dist/credentials/TravisCiApi.credentials.js",
"dist/credentials/TrelloApi.credentials.js",
"dist/credentials/TwilioApi.credentials.js",
@ -207,6 +209,8 @@
"dist/nodes/Cockpit/Cockpit.node.js",
"dist/nodes/Coda/Coda.node.js",
"dist/nodes/Contentful/Contentful.node.js",
"dist/nodes/ConvertKit/ConvertKit.node.js",
"dist/nodes/ConvertKit/ConvertKitTrigger.node.js",
"dist/nodes/Copper/CopperTrigger.node.js",
"dist/nodes/CrateDb/CrateDb.node.js",
"dist/nodes/Cron.node.js",