{
const currentWorkflow = id || this.$route.params.name;
if (!currentWorkflow || ['new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(currentWorkflow)) {
@@ -726,7 +726,7 @@ export const workflowHelpers = mixins(
workflowDataRequest.hash = this.workflowsStore.workflowHash;
- const workflowData = await this.restApi().updateWorkflow(currentWorkflow, workflowDataRequest);
+ const workflowData = await this.restApi().updateWorkflow(currentWorkflow, workflowDataRequest, forceSave);
this.workflowsStore.setWorkflowHash(workflowData.hash);
if (name) {
@@ -747,6 +747,22 @@ export const workflowHelpers = mixins(
} catch (error) {
this.uiStore.removeActiveAction('workflowSaving');
+ if (error.errorCode === 400 && error.message.startsWith('Your most recent changes may be lost')) {
+ const overwrite = await this.confirmMessage(
+ this.$locale.baseText('workflows.concurrentChanges.confirmMessage.message'),
+ this.$locale.baseText('workflows.concurrentChanges.confirmMessage.title'),
+ null,
+ this.$locale.baseText('workflows.concurrentChanges.confirmMessage.confirmButtonText'),
+ this.$locale.baseText('workflows.concurrentChanges.confirmMessage.cancelButtonText'),
+ );
+
+ if (overwrite) {
+ return this.saveCurrentWorkflow({id, name, tags}, redirect, true);
+ }
+
+ return false;
+ }
+
this.$showMessage({
title: this.$locale.baseText('workflowHelpers.showMessage.title'),
message: error.message,
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index 29fc447e38e13..6b997a625322e 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -1399,6 +1399,10 @@
"workflows.shareModal.notAvailable": "Sharing workflows with others is currently available only on n8n cloud, our hosted offering.",
"workflows.shareModal.notAvailable.button": "Explore n8n cloud",
"workflows.roles.editor": "Editor",
+ "workflows.concurrentChanges.confirmMessage.title": "Workflow was edited by someone else",
+ "workflows.concurrentChanges.confirmMessage.message": "Another user made an edit to this workflow since you last saved it. Do you want to overwrite their changes?",
+ "workflows.concurrentChanges.confirmMessage.cancelButtonText": "Cancel",
+ "workflows.concurrentChanges.confirmMessage.confirmButtonText": "Overwrite then save",
"importCurlModal.title": "Import cURL command",
"importCurlModal.input.label": "cURL Command",
"importCurlModal.input.placeholder": "Paste the cURL command here",
diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue
index 6994d5398b051..ca3d7d7ab271c 100644
--- a/packages/editor-ui/src/views/NodeView.vue
+++ b/packages/editor-ui/src/views/NodeView.vue
@@ -3,6 +3,7 @@
diff --git a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts
index 0319df5678ad4..ae0a9e6e7e448 100644
--- a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts
+++ b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts
@@ -166,19 +166,13 @@ export async function parseRawEmail(
dataPropertyNameDownload: string,
): Promise {
const messageEncoded = Buffer.from(messageData.raw, 'base64').toString('utf8');
- let responseData = await simpleParser(messageEncoded);
+ const responseData = await simpleParser(messageEncoded);
const headers: IDataObject = {};
- // @ts-ignore
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) {
const downloadAttachments = this.getNodeParameter(
@@ -196,8 +190,6 @@ export async function parseRawEmail(
);
}
}
- // @ts-ignore
- responseData.attachments = undefined;
}
const mailBaseData: IDataObject = {};
@@ -205,14 +197,17 @@ export async function parseRawEmail(
const resolvedModeAddProperties = ['id', 'threadId', 'labelIds', 'sizeEstimate'];
for (const key of resolvedModeAddProperties) {
- // @ts-ignore
mailBaseData[key] = messageData[key];
}
- responseData = Object.assign(mailBaseData, responseData);
+ const json = Object.assign({}, mailBaseData, responseData, {
+ headers,
+ headerLines: undefined,
+ attachments: undefined,
+ }) as IDataObject;
return {
- json: responseData as unknown as IDataObject,
+ json,
binary: Object.keys(binaryData).length ? binaryData : undefined,
} as INodeExecutionData;
}
@@ -390,6 +385,14 @@ export function prepareQuery(
let timestamp = DateTime.fromISO(qs.receivedAfter as string).toSeconds();
const timestampLengthInMilliseconds1990 = 12;
+ if (
+ !timestamp &&
+ typeof qs.receivedAfter === 'number' &&
+ qs.receivedAfter.toString().length < timestampLengthInMilliseconds1990
+ ) {
+ timestamp = qs.receivedAfter;
+ }
+
if (!timestamp && (qs.receivedAfter as string).length < timestampLengthInMilliseconds1990) {
timestamp = parseInt(qs.receivedAfter as string, 10);
}
diff --git a/packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts b/packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts
index fe663c2490c87..2634463b2f7a2 100644
--- a/packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts
+++ b/packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts
@@ -194,8 +194,8 @@ export class GmailTrigger implements INodeType {
let responseData;
const now = Math.floor(DateTime.now().toSeconds()) + '';
- const startDate = (webhookData.lastTimeChecked as string) || now;
- const endDate = now;
+ const startDate = (webhookData.lastTimeChecked as string) || +now;
+ const endDate = +now;
const options = this.getNodeParameter('options', {}) as IDataObject;
const filters = this.getNodeParameter('filters', {}) as IDataObject;
@@ -273,7 +273,38 @@ export class GmailTrigger implements INodeType {
);
}
- webhookData.lastTimeChecked = endDate;
+ const getEmailDateAsSeconds = (email: IDataObject) => {
+ const { internalDate, date } = email;
+ return internalDate
+ ? +(internalDate as string) / 1000
+ : +DateTime.fromJSDate(new Date(date as string)).toSeconds();
+ };
+
+ const lastEmailDate = (responseData as IDataObject[]).reduce((lastDate, { json }) => {
+ const emailDate = getEmailDateAsSeconds(json as IDataObject);
+ return emailDate > lastDate ? emailDate : lastDate;
+ }, 0);
+
+ const nextPollPossibleDuplicates = (responseData as IDataObject[]).reduce(
+ (duplicates, { json }) => {
+ const emailDate = getEmailDateAsSeconds(json as IDataObject);
+ return emailDate === lastEmailDate
+ ? duplicates.concat((json as IDataObject).id as string)
+ : duplicates;
+ },
+ [] as string[],
+ );
+
+ const possibleDuplicates = (webhookData.possibleDuplicates as string[]) || [];
+ if (possibleDuplicates.length) {
+ responseData = (responseData as IDataObject[]).filter(({ json }) => {
+ const { id } = json as IDataObject;
+ return !possibleDuplicates.includes(id as string);
+ });
+ }
+
+ webhookData.possibleDuplicates = nextPollPossibleDuplicates;
+ webhookData.lastTimeChecked = lastEmailDate || endDate;
if (Array.isArray(responseData) && responseData.length) {
return [responseData as INodeExecutionData[]];