feat(core): Add support to import/export tags (#3130)

* Export and Import Workflow Tags

Support exporting and importing tags of workflows via frontend and cli.

On export, all tag data is included in the json.
- id
- name
- updatedAt
- createdAt

When importing a workflow json to n8n we:
- first check if a tag with the same id and createdAt date exists in the
  database, then we can assume the tag is identical. Changes on the name
  of the tag are now preserved.
- check if a tag with the same name exists on the database.
- create a new tag with the given name.

* clean up fe export

* remove usage count

* return updatedat, createdat

* fix tags import

* move logic from workflow package

* refactor import

* check for tags before import

* update checks on type

* fix on import

* fix build issues

* fix type issue

* remove unnessary ?

* update tag helpers so only name is required

* fix tag import

* add don't replace existing tags

* fix build issue

* address comments

* fix with promise.all

* update setting tags

* update check

* fix existing check

* add helper

* fix duplication

* fix multiple same tags bug

* fix db bugs

* add more validation on workflow type

* fix validation

* disable importing tags on copy paste

Co-authored-by: Luca Berneking <l.berneking@mittwald.de>
This commit is contained in:
Mutasem Aldmour 2022-06-02 12:39:42 +02:00 committed by GitHub
parent 042b8daf1c
commit 15a20d257d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 179 additions and 22 deletions

View file

@ -110,8 +110,10 @@ export class ExportWorkflowsCommand extends Command {
findQuery.id = flags.id; findQuery.id = flags.id;
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const workflows = await Db.collections.Workflow.find({
const workflows = await Db.collections.Workflow.find(findQuery); where: findQuery,
relations: ['tags'],
});
if (workflows.length === 0) { if (workflows.length === 0) {
throw new Error('No workflows found with specified filters.'); throw new Error('No workflows found with specified filters.');

View file

@ -17,15 +17,34 @@ import glob from 'fast-glob';
import { UserSettings } from 'n8n-core'; import { UserSettings } from 'n8n-core';
import { EntityManager, getConnection } from 'typeorm'; import { EntityManager, getConnection } from 'typeorm';
import { getLogger } from '../../src/Logger'; import { getLogger } from '../../src/Logger';
import { Db, ICredentialsDb } from '../../src'; import { Db, ICredentialsDb, IWorkflowToImport } from '../../src';
import { SharedWorkflow } from '../../src/databases/entities/SharedWorkflow'; import { SharedWorkflow } from '../../src/databases/entities/SharedWorkflow';
import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity';
import { Role } from '../../src/databases/entities/Role'; import { Role } from '../../src/databases/entities/Role';
import { User } from '../../src/databases/entities/User'; import { User } from '../../src/databases/entities/User';
import { setTagsForImport } from '../../src/TagHelpers';
const FIX_INSTRUCTION = const FIX_INSTRUCTION =
'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset';
function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] {
if (!Array.isArray(workflows)) {
throw new Error(
'File does not seem to contain workflows. Make sure the workflows are contained in an array.',
);
}
for (const workflow of workflows) {
if (
typeof workflow !== 'object' ||
!Object.prototype.hasOwnProperty.call(workflow, 'nodes') ||
!Object.prototype.hasOwnProperty.call(workflow, 'connections')
) {
throw new Error('File does not seem to contain valid workflows.');
}
}
}
export class ImportWorkflowsCommand extends Command { export class ImportWorkflowsCommand extends Command {
static description = 'Import workflows'; static description = 'Import workflows';
@ -82,7 +101,8 @@ export class ImportWorkflowsCommand extends Command {
// Make sure the settings exist // Make sure the settings exist
await UserSettings.prepareUserSettings(); await UserSettings.prepareUserSettings();
const credentials = (await Db.collections.Credentials.find()) ?? []; const credentials = await Db.collections.Credentials.find();
const tags = await Db.collections.Tag.find();
let totalImported = 0; let totalImported = 0;
@ -111,6 +131,10 @@ export class ImportWorkflowsCommand extends Command {
}); });
} }
if (Object.prototype.hasOwnProperty.call(workflow, 'tags')) {
await setTagsForImport(transactionManager, workflow, tags);
}
await this.storeWorkflow(workflow, user); await this.storeWorkflow(workflow, user);
} }
}); });
@ -121,13 +145,9 @@ export class ImportWorkflowsCommand extends Command {
const workflows = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' })); const workflows = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' }));
totalImported = workflows.length; assertHasWorkflowsToImport(workflows);
if (!Array.isArray(workflows)) { totalImported = workflows.length;
throw new Error(
'File does not seem to contain workflows. Make sure the workflows are contained in an array.',
);
}
await getConnection().transaction(async (transactionManager) => { await getConnection().transaction(async (transactionManager) => {
this.transactionManager = transactionManager; this.transactionManager = transactionManager;
@ -139,6 +159,10 @@ export class ImportWorkflowsCommand extends Command {
}); });
} }
if (Object.prototype.hasOwnProperty.call(workflow, 'tags')) {
await setTagsForImport(transactionManager, workflow, tags);
}
await this.storeWorkflow(workflow, user); await this.storeWorkflow(workflow, user);
} }
}); });

View file

@ -114,6 +114,13 @@ export interface ITagDb {
updatedAt: Date; updatedAt: Date;
} }
export interface ITagToImport {
id: string | number;
name: string;
createdAt?: string;
updatedAt?: string;
}
export type UsageCount = { export type UsageCount = {
usageCount: number; usageCount: number;
}; };
@ -134,6 +141,10 @@ export interface IWorkflowDb extends IWorkflowBase {
tags: ITagDb[]; tags: ITagDb[];
} }
export interface IWorkflowToImport extends IWorkflowBase {
tags: ITagToImport[];
}
export interface IWorkflowResponse extends IWorkflowBase { export interface IWorkflowResponse extends IWorkflowBase {
id: string; id: string;
} }

View file

@ -1241,7 +1241,7 @@ class App {
return TagHelpers.getTagsWithCountDb(tablePrefix); return TagHelpers.getTagsWithCountDb(tablePrefix);
} }
return Db.collections.Tag.find({ select: ['id', 'name'] }); return Db.collections.Tag.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] });
}, },
), ),
); );

View file

@ -1,11 +1,11 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable import/no-cycle */ /* eslint-disable import/no-cycle */
import { getConnection } from 'typeorm'; import { EntityManager, getConnection } from 'typeorm';
import { TagEntity } from './databases/entities/TagEntity'; import { TagEntity } from './databases/entities/TagEntity';
import { ITagWithCountDb } from './Interfaces'; import { ITagToImport, ITagWithCountDb, IWorkflowToImport } from './Interfaces';
// ---------------------------------- // ----------------------------------
// utils // utils
@ -38,6 +38,8 @@ export async function getTagsWithCountDb(tablePrefix: string): Promise<ITagWithC
.createQueryBuilder() .createQueryBuilder()
.select(`${tablePrefix}tag_entity.id`, 'id') .select(`${tablePrefix}tag_entity.id`, 'id')
.addSelect(`${tablePrefix}tag_entity.name`, 'name') .addSelect(`${tablePrefix}tag_entity.name`, 'name')
.addSelect(`${tablePrefix}tag_entity.createdAt`, 'createdAt')
.addSelect(`${tablePrefix}tag_entity.updatedAt`, 'updatedAt')
.addSelect(`COUNT(${tablePrefix}workflows_tags.workflowId)`, 'usageCount') .addSelect(`COUNT(${tablePrefix}workflows_tags.workflowId)`, 'usageCount')
.from(`${tablePrefix}tag_entity`, 'tag_entity') .from(`${tablePrefix}tag_entity`, 'tag_entity')
.leftJoin( .leftJoin(
@ -86,3 +88,70 @@ export async function removeRelations(workflowId: string, tablePrefix: string) {
.where('workflowId = :id', { id: workflowId }) .where('workflowId = :id', { id: workflowId })
.execute(); .execute();
} }
const createTag = async (transactionManager: EntityManager, name: string): Promise<TagEntity> => {
const tag = new TagEntity();
tag.name = name;
return transactionManager.save<TagEntity>(tag);
};
const findOrCreateTag = async (
transactionManager: EntityManager,
importTag: ITagToImport,
tagsEntities: TagEntity[],
): Promise<TagEntity> => {
// Assume tag is identical if createdAt date is the same to preserve a changed tag name
const identicalMatch = tagsEntities.find(
(existingTag) =>
existingTag.id.toString() === importTag.id.toString() &&
existingTag.createdAt &&
importTag.createdAt &&
existingTag.createdAt.getTime() === new Date(importTag.createdAt).getTime(),
);
if (identicalMatch) {
return identicalMatch;
}
const nameMatch = tagsEntities.find((existingTag) => existingTag.name === importTag.name);
if (nameMatch) {
return nameMatch;
}
const created = await createTag(transactionManager, importTag.name);
tagsEntities.push(created);
return created;
};
const hasTags = (workflow: IWorkflowToImport) =>
'tags' in workflow && Array.isArray(workflow.tags) && workflow.tags.length > 0;
/**
* Set tag IDs to use existing tags, creates a new tag if no matching tag could be found
*/
export async function setTagsForImport(
transactionManager: EntityManager,
workflow: IWorkflowToImport,
tags: TagEntity[],
): Promise<void> {
if (!hasTags(workflow)) {
return;
}
const workflowTags = workflow.tags;
const tagLookupPromises = [];
for (let i = 0; i < workflowTags.length; i++) {
if (workflowTags[i]?.name) {
const lookupPromise = findOrCreateTag(transactionManager, workflowTags[i], tags).then(
(tag) => {
workflowTags[i] = {
id: tag.id,
name: tag.name,
};
},
);
tagLookupPromises.push(lookupPromise);
}
}
await Promise.all(tagLookupPromises);
}

View file

@ -733,6 +733,8 @@ export interface ITag {
id: string; id: string;
name: string; name: string;
usageCount?: number; usageCount?: number;
createdAt?: string;
updatedAt?: string;
} }
export interface ITagRow { export interface ITagRow {

View file

@ -511,10 +511,21 @@ export default mixins(
if (data.id && typeof data.id === 'string') { if (data.id && typeof data.id === 'string') {
data.id = parseInt(data.id, 10); data.id = parseInt(data.id, 10);
} }
const blob = new Blob([JSON.stringify(data, null, 2)], {
const exportData: IWorkflowDataUpdate = {
...data,
tags: (tags || []).map(tagId => {
const {usageCount, ...tag} = this.$store.getters["tags/getTagById"](tagId);
return tag;
}),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json;charset=utf-8', type: 'application/json;charset=utf-8',
}); });
let workflowName = this.$store.getters.workflowName || 'unsaved_workflow'; let workflowName = this.$store.getters.workflowName || 'unsaved_workflow';
workflowName = workflowName.replace(/[^a-z0-9]/gi, '_'); workflowName = workflowName.replace(/[^a-z0-9]/gi, '_');

View file

@ -64,10 +64,10 @@ const module: Module<ITagsState, IRootState> = {
}, },
}, },
actions: { actions: {
fetchAll: async (context: ActionContext<ITagsState, IRootState>, params?: { force?: boolean, withUsageCount?: boolean }) => { fetchAll: async (context: ActionContext<ITagsState, IRootState>, params?: { force?: boolean, withUsageCount?: boolean }): Promise<ITag[]> => {
const { force = false, withUsageCount = false } = params || {}; const { force = false, withUsageCount = false } = params || {};
if (!force && context.state.fetchedAll && context.state.fetchedUsageCount === withUsageCount) { if (!force && context.state.fetchedAll && context.state.fetchedUsageCount === withUsageCount) {
return context.state.tags; return Object.values(context.state.tags);
} }
context.commit('setLoading', true); context.commit('setLoading', true);
@ -77,7 +77,7 @@ const module: Module<ITagsState, IRootState> = {
return tags; return tags;
}, },
create: async (context: ActionContext<ITagsState, IRootState>, name: string) => { create: async (context: ActionContext<ITagsState, IRootState>, name: string): Promise<ITag> => {
const tag = await createTag(context.rootGetters.getRestApiContext, { name }); const tag = await createTag(context.rootGetters.getRestApiContext, { name });
context.commit('upsertTags', [tag]); context.commit('upsertTags', [tag]);
@ -88,7 +88,7 @@ const module: Module<ITagsState, IRootState> = {
context.commit('upsertTags', [tag]); context.commit('upsertTags', [tag]);
return tag; return tag;
}, },
delete: async (context: ActionContext<ITagsState, IRootState>, id: string) => { delete: async (context: ActionContext<ITagsState, IRootState>, id: string) => {
const deleted = await deleteTag(context.rootGetters.getRestApiContext, id); const deleted = await deleteTag(context.rootGetters.getRestApiContext, id);
@ -98,8 +98,8 @@ const module: Module<ITagsState, IRootState> = {
} }
return deleted; return deleted;
}, },
}, },
}; };
export default module; export default module;

View file

@ -611,6 +611,10 @@ export const store = new Vuex.Store({
Vue.set(state.workflow, 'tags', tags); Vue.set(state.workflow, 'tags', tags);
}, },
addWorkflowTagIds (state, tags: string[]) {
Vue.set(state.workflow, 'tags', [...new Set([...(state.workflow.tags || []), ...tags])]);
},
removeWorkflowTagId (state, tagId: string) { removeWorkflowTagId (state, tagId: string) {
const tags = state.workflow.tags as string[]; const tags = state.workflow.tags as string[];
const updated = tags.filter((id: string) => id !== tagId); const updated = tags.filter((id: string) => id !== tagId);

View file

@ -1232,7 +1232,7 @@ export default mixins(
workflow_id: this.$store.getters.workflowId, workflow_id: this.$store.getters.workflowId,
}); });
return this.importWorkflowData(workflowData!); return this.importWorkflowData(workflowData!, false);
}, },
// Returns the workflow data from a given URL. If no data gets found or // Returns the workflow data from a given URL. If no data gets found or
@ -1259,7 +1259,7 @@ export default mixins(
}, },
// Imports the given workflow data into the current workflow // Imports the given workflow data into the current workflow
async importWorkflowData (workflowData: IWorkflowDataUpdate): Promise<void> { async importWorkflowData (workflowData: IWorkflowDataUpdate, importTags = true): Promise<void> {
// If it is JSON check if it looks on the first look like data we can use // If it is JSON check if it looks on the first look like data we can use
if ( if (
!workflowData.hasOwnProperty('nodes') || !workflowData.hasOwnProperty('nodes') ||
@ -1285,6 +1285,40 @@ export default mixins(
this.nodeSelectedByName(node.name); this.nodeSelectedByName(node.name);
}); });
}); });
const tagsEnabled = this.$store.getters['settings/areTagsEnabled'];
if (importTags && tagsEnabled && Array.isArray(workflowData.tags)) {
const allTags: ITag[] = await this.$store.dispatch('tags/fetchAll');
const tagNames = new Set(allTags.map((tag) => tag.name));
const workflowTags = workflowData.tags as ITag[];
const notFound = workflowTags.filter((tag) => !tagNames.has(tag.name));
const creatingTagPromises: Array<Promise<ITag>> = [];
for (const tag of notFound) {
const creationPromise = this.$store.dispatch('tags/create', tag.name)
.then((tag: ITag) => {
allTags.push(tag);
return tag;
});
creatingTagPromises.push(creationPromise);
}
await Promise.all(creatingTagPromises);
const tagIds = workflowTags.reduce((accu: string[], imported: ITag) => {
const tag = allTags.find(tag => tag.name === imported.name);
if (tag) {
accu.push(tag.id);
}
return accu;
}, []);
this.$store.commit('addWorkflowTagIds', tagIds);
}
} catch (error) { } catch (error) {
this.$showError( this.$showError(
error, error,