Skip to content

Commit

Permalink
feat(core): Add support to import/export tags (n8n-io#3130)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
2 people authored and simshaun committed Jun 4, 2022
1 parent 6de7861 commit 2055e48
Show file tree
Hide file tree
Showing 10 changed files with 179 additions and 22 deletions.
6 changes: 4 additions & 2 deletions packages/cli/commands/export/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,10 @@ export class ExportWorkflowsCommand extends Command {
findQuery.id = flags.id;
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const workflows = await Db.collections.Workflow.find(findQuery);
const workflows = await Db.collections.Workflow.find({
where: findQuery,
relations: ['tags'],
});

if (workflows.length === 0) {
throw new Error('No workflows found with specified filters.');
Expand Down
40 changes: 32 additions & 8 deletions packages/cli/commands/import/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,34 @@ import glob from 'fast-glob';
import { UserSettings } from 'n8n-core';
import { EntityManager, getConnection } from 'typeorm';
import { getLogger } from '../../src/Logger';
import { Db, ICredentialsDb } from '../../src';
import { Db, ICredentialsDb, IWorkflowToImport } from '../../src';
import { SharedWorkflow } from '../../src/databases/entities/SharedWorkflow';
import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity';
import { Role } from '../../src/databases/entities/Role';
import { User } from '../../src/databases/entities/User';
import { setTagsForImport } from '../../src/TagHelpers';

const FIX_INSTRUCTION =
'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 {
static description = 'Import workflows';

Expand Down Expand Up @@ -82,7 +101,8 @@ export class ImportWorkflowsCommand extends Command {

// Make sure the settings exist
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;

Expand Down Expand Up @@ -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);
}
});
Expand All @@ -121,13 +145,9 @@ export class ImportWorkflowsCommand extends Command {

const workflows = JSON.parse(fs.readFileSync(flags.input, { encoding: 'utf8' }));

totalImported = workflows.length;
assertHasWorkflowsToImport(workflows);

if (!Array.isArray(workflows)) {
throw new Error(
'File does not seem to contain workflows. Make sure the workflows are contained in an array.',
);
}
totalImported = workflows.length;

await getConnection().transaction(async (transactionManager) => {
this.transactionManager = transactionManager;
Expand All @@ -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);
}
});
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ export interface ITagDb {
updatedAt: Date;
}

export interface ITagToImport {
id: string | number;
name: string;
createdAt?: string;
updatedAt?: string;
}

export type UsageCount = {
usageCount: number;
};
Expand All @@ -134,6 +141,10 @@ export interface IWorkflowDb extends IWorkflowBase {
tags: ITagDb[];
}

export interface IWorkflowToImport extends IWorkflowBase {
tags: ITagToImport[];
}

export interface IWorkflowResponse extends IWorkflowBase {
id: string;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1241,7 +1241,7 @@ class App {
return TagHelpers.getTagsWithCountDb(tablePrefix);
}

return Db.collections.Tag.find({ select: ['id', 'name'] });
return Db.collections.Tag.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] });
},
),
);
Expand Down
73 changes: 71 additions & 2 deletions packages/cli/src/TagHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable import/no-cycle */
import { getConnection } from 'typeorm';
import { EntityManager, getConnection } from 'typeorm';

import { TagEntity } from './databases/entities/TagEntity';

import { ITagWithCountDb } from './Interfaces';
import { ITagToImport, ITagWithCountDb, IWorkflowToImport } from './Interfaces';

// ----------------------------------
// utils
Expand Down Expand Up @@ -38,6 +38,8 @@ export async function getTagsWithCountDb(tablePrefix: string): Promise<ITagWithC
.createQueryBuilder()
.select(`${tablePrefix}tag_entity.id`, 'id')
.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')
.from(`${tablePrefix}tag_entity`, 'tag_entity')
.leftJoin(
Expand Down Expand Up @@ -86,3 +88,70 @@ export async function removeRelations(workflowId: string, tablePrefix: string) {
.where('workflowId = :id', { id: workflowId })
.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);
}
2 changes: 2 additions & 0 deletions packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,8 @@ export interface ITag {
id: string;
name: string;
usageCount?: number;
createdAt?: string;
updatedAt?: string;
}

export interface ITagRow {
Expand Down
13 changes: 12 additions & 1 deletion packages/editor-ui/src/components/MainSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -511,10 +511,21 @@ export default mixins(
if (data.id && typeof data.id === 'string') {
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',
});
let workflowName = this.$store.getters.workflowName || 'unsaved_workflow';
workflowName = workflowName.replace(/[^a-z0-9]/gi, '_');
Expand Down
12 changes: 6 additions & 6 deletions packages/editor-ui/src/modules/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ const module: Module<ITagsState, IRootState> = {
},
},
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 || {};
if (!force && context.state.fetchedAll && context.state.fetchedUsageCount === withUsageCount) {
return context.state.tags;
return Object.values(context.state.tags);
}

context.commit('setLoading', true);
Expand All @@ -77,7 +77,7 @@ const module: Module<ITagsState, IRootState> = {

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 });
context.commit('upsertTags', [tag]);

Expand All @@ -88,7 +88,7 @@ const module: Module<ITagsState, IRootState> = {
context.commit('upsertTags', [tag]);

return tag;
},
},
delete: async (context: ActionContext<ITagsState, IRootState>, id: string) => {
const deleted = await deleteTag(context.rootGetters.getRestApiContext, id);

Expand All @@ -98,8 +98,8 @@ const module: Module<ITagsState, IRootState> = {
}

return deleted;
},
},
},
};

export default module;
export default module;
4 changes: 4 additions & 0 deletions packages/editor-ui/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,10 @@ export const store = new Vuex.Store({
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) {
const tags = state.workflow.tags as string[];
const updated = tags.filter((id: string) => id !== tagId);
Expand Down
38 changes: 36 additions & 2 deletions packages/editor-ui/src/views/NodeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1232,7 +1232,7 @@ export default mixins(
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
Expand All @@ -1259,7 +1259,7 @@ export default mixins(
},
// 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 (
!workflowData.hasOwnProperty('nodes') ||
Expand All @@ -1285,6 +1285,40 @@ export default mixins(
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) {
this.$showError(
error,
Expand Down

0 comments on commit 2055e48

Please sign in to comment.