From 8a95afd75625f947d14d1d93139531b633b161bc Mon Sep 17 00:00:00 2001 From: azivner Date: Fri, 27 Jul 2018 10:52:48 +0200 Subject: [PATCH] #126, added skeleton of note relations, copied from similar concept of labels --- .../a2c75661-f9e2-478f-a69f-6a9409e69997.xml | 318 +++++++++++------- db/migrations/0106__add_relations_table.sql | 15 + src/entities/entity_constructor.js | 4 + src/entities/relation.js | 40 +++ src/public/javascripts/dialogs/relations.js | 222 ++++++++++++ .../javascripts/services/entrypoints.js | 4 + src/routes/api/relations.js | 63 ++++ src/routes/routes.js | 5 + src/services/app_info.js | 2 +- src/services/relations.js | 44 +++ src/services/repository.js | 5 + src/services/sync.js | 1 + src/services/sync_table.js | 6 + src/services/sync_update.js | 17 + src/views/index.ejs | 45 +++ 15 files changed, 662 insertions(+), 129 deletions(-) create mode 100644 db/migrations/0106__add_relations_table.sql create mode 100644 src/entities/relation.js create mode 100644 src/public/javascripts/dialogs/relations.js create mode 100644 src/routes/api/relations.js create mode 100644 src/services/relations.js diff --git a/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml b/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml index 7c8dd0bfb..b97e9851c 100644 --- a/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml +++ b/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml @@ -21,533 +21,529 @@
-
-
+
+
+
1
- +
1
- - +
+ 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 INT|0s 1 0 - + 5 TEXT|0s 1 "" - + 1 apiTokenId 1 - + apiTokenId 1 sqlite_autoindex_api_tokens_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 INTEGER|0s 1 - + 5 TEXT|0s - + 6 BOOLEAN|0s - + 7 INTEGER|0s 1 0 - + 8 TEXT|0s 1 - + 9 TEXT|0s 1 "" - + 10 TEXT|0s 1 '1970-01-01T00:00:00.000Z' - + 1 branchId 1 - + noteId parentNoteId - + noteId - + parentNoteId - + branchId 1 sqlite_autoindex_branches_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s - + 3 TEXT|0s - + 4 TEXT|0s 1 - + 1 eventId 1 - + eventId 1 sqlite_autoindex_event_log_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 TEXT|0s 1 - + 5 BLOB|0s - + 6 INT|0s 1 0 - + 7 TEXT|0s 1 - + 8 TEXT|0s 1 - + 9 TEXT|0s 1 "" - + 1 imageId 1 - + imageId 1 sqlite_autoindex_images_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 TEXT|0s 1 '' - + 5 INT|0s 1 0 - + 6 TEXT|0s 1 - + 7 TEXT|0s 1 - + 8 INT|0s 1 - + 9 TEXT|0s 1 "" - + 1 labelId 1 - + noteId - + name value - + labelId 1 sqlite_autoindex_labels_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 INT|0s 1 0 - + 5 TEXT|0s 1 - + 6 TEXT|0s 1 - + 7 TEXT|0s 1 "" - + 1 noteImageId 1 - + noteId imageId - + noteId - + imageId - + noteImageId 1 sqlite_autoindex_note_images_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s - + 4 TEXT|0s - + 5 INT|0s 1 0 - + 6 TEXT|0s 1 - + 7 TEXT|0s 1 - + 8 TEXT|0s 1 '' - + 9 TEXT|0s 1 '' - + 10 TEXT|0s 1 "" - + 1 noteRevisionId 1 - + noteId - + dateModifiedFrom - + dateModifiedTo - + noteRevisionId 1 sqlite_autoindex_note_revisions_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 "unnamed" - + 3 TEXT|0s 1 "" - + 4 INT|0s 1 0 - + 5 INT|0s 1 0 - + 6 TEXT|0s 1 - + 7 TEXT|0s 1 - + 8 TEXT|0s 1 'text' - + 9 TEXT|0s 1 'text/html' - + 10 TEXT|0s 1 "" - + 1 noteId 1 - + type - + noteId 1 sqlite_autoindex_notes_1 - + 1 TEXT|0s 1 - - 2 - TEXT|0s - 1 - - 3 + 2 TEXT|0s - 4 + 3 INT|0s - 5 + 4 INTEGER|0s 1 0 - 6 + 5 TEXT|0s 1 "" - 7 + 6 TEXT|0s 1 '1970-01-01T00:00:00.000Z' 1 - optionId + name 1 - optionId + name 1 sqlite_autoindex_options_1 @@ -587,90 +583,156 @@ imageId 1 sqlite_autoindex_recent_notes_1 - + 1 TEXT|0s 1 - + 2 TEXT|0s 1 - + + 3 + TEXT|0s + 1 + + + 4 + TEXT|0s + 1 + + + 5 + INT|0s + 1 + 0 + + + 6 + TEXT|0s + 1 + + + 7 + TEXT|0s + 1 + + + 8 + INT|0s + 1 + + + 9 + TEXT|0s + 1 + "" + + + 1 + relationId + + 1 + + + sourceNoteId + + + + targetNoteId + + + + relationId + 1 + sqlite_autoindex_relations_1 + + + 1 + TEXT|0s + 1 + + + 2 + TEXT|0s + 1 + + 1 sourceId 1 - + sourceId 1 sqlite_autoindex_source_ids_1 - + 1 text|0s - + 2 text|0s - + 3 text|0s - + 4 integer|0s - + 5 text|0s - + 1 - + 2 - + 1 INTEGER|0s 1 1 - + 2 TEXT|0s 1 - + 3 TEXT|0s 1 - + 4 TEXT|0s 1 - + 5 TEXT|0s 1 - + entityName entityId 1 - + syncDate - + id 1 diff --git a/db/migrations/0106__add_relations_table.sql b/db/migrations/0106__add_relations_table.sql new file mode 100644 index 000000000..9cc5cb84e --- /dev/null +++ b/db/migrations/0106__add_relations_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE relations +( + relationId TEXT not null primary key, + sourceNoteId TEXT not null, + name TEXT not null, + targetNoteId TEXT not null, + position INT default 0 not null, + dateCreated TEXT not null, + dateModified TEXT not null, + isDeleted INT not null + , hash TEXT DEFAULT "" NOT NULL); +CREATE INDEX IDX_relation_sourceNoteId + on relations (sourceNoteId); +CREATE INDEX IDX_relation_targetNoteId + on relations (targetNoteId); \ No newline at end of file diff --git a/src/entities/entity_constructor.js b/src/entities/entity_constructor.js index 5db7c0963..b0faf65d9 100644 --- a/src/entities/entity_constructor.js +++ b/src/entities/entity_constructor.js @@ -4,6 +4,7 @@ const Image = require('../entities/image'); const NoteImage = require('../entities/note_image'); const Branch = require('../entities/branch'); const Label = require('../entities/label'); +const Relation = require('../entities/relation'); const RecentNote = require('../entities/recent_note'); const ApiToken = require('../entities/api_token'); const Option = require('../entities/option'); @@ -15,6 +16,9 @@ function createEntityFromRow(row) { if (row.labelId) { entity = new Label(row); } + else if (row.relationId) { + entity = new Relation(row); + } else if (row.noteRevisionId) { entity = new NoteRevision(row); } diff --git a/src/entities/relation.js b/src/entities/relation.js new file mode 100644 index 000000000..25c2760e8 --- /dev/null +++ b/src/entities/relation.js @@ -0,0 +1,40 @@ +"use strict"; + +const Entity = require('./entity'); +const repository = require('../services/repository'); +const dateUtils = require('../services/date_utils'); +const sql = require('../services/sql'); + +class Relation extends Entity { + static get tableName() { return "relations"; } + static get primaryKeyName() { return "relationId"; } + static get hashedProperties() { return ["relationId", "sourceNoteId", "name", "targetNoteId", "dateModified", "dateCreated"]; } + + async getSourceNote() { + return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.sourceNoteId]); + } + + async getTargetNote() { + return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.targetNoteId]); + } + + async beforeSaving() { + super.beforeSaving(); + + if (this.position === undefined) { + this.position = 1 + await sql.getValue(`SELECT COALESCE(MAX(position), 0) FROM relations WHERE sourceNoteId = ?`, [this.sourceNoteId]); + } + + if (!this.isDeleted) { + this.isDeleted = false; + } + + if (!this.dateCreated) { + this.dateCreated = dateUtils.nowDate(); + } + + this.dateModified = dateUtils.nowDate(); + } +} + +module.exports = Relation; \ No newline at end of file diff --git a/src/public/javascripts/dialogs/relations.js b/src/public/javascripts/dialogs/relations.js new file mode 100644 index 000000000..5455bf731 --- /dev/null +++ b/src/public/javascripts/dialogs/relations.js @@ -0,0 +1,222 @@ +import noteDetailService from '../services/note_detail.js'; +import server from '../services/server.js'; +import infoService from "../services/info.js"; + +const $dialog = $("#relations-dialog"); +const $saveRelationsButton = $("#save-relations-button"); +const $relationsBody = $('#relations-table tbody'); + +const relationsModel = new RelationsModel(); +let relationNames = []; + +function RelationsModel() { + const self = this; + + this.relations = ko.observableArray(); + + this.updateRelationPositions = function() { + let position = 0; + + // we need to update positions by searching in the DOM, because order of the + // relations in the viewmodel (self.relations()) stays the same + $relationsBody.find('input[name="position"]').each(function() { + const relation = self.getTargetRelation(this); + + relation().position = position++; + }); + }; + + this.loadRelations = async function() { + const noteId = noteDetailService.getCurrentNoteId(); + + const relations = await server.get('notes/' + noteId + '/relations'); + + self.relations(relations.map(ko.observable)); + + addLastEmptyRow(); + + relationNames = await server.get('relations/names'); + + // relation might not be rendered immediatelly so could not focus + setTimeout(() => $(".relation-name:last").focus(), 100); + + $relationsBody.sortable({ + handle: '.handle', + containment: $relationsBody, + update: this.updateRelationPositions + }); + }; + + this.deleteRelation = function(data, event) { + const relation = self.getTargetRelation(event.target); + const relationData = relation(); + + if (relationData) { + relationData.isDeleted = 1; + + relation(relationData); + + addLastEmptyRow(); + } + }; + + function isValid() { + for (let relations = self.relations(), i = 0; i < relations.length; i++) { + if (self.isEmptyName(i)) { + return false; + } + } + + return true; + } + + this.save = async function() { + // we need to defocus from input (in case of enter-triggered save) because value is updated + // on blur event (because of conflict with jQuery UI Autocomplete). Without this, input would + // stay in focus, blur wouldn't be triggered and change wouldn't be updated in the viewmodel. + $saveRelationsButton.focus(); + + if (!isValid()) { + alert("Please fix all validation errors and try saving again."); + return; + } + + self.updateRelationPositions(); + + const noteId = noteDetailService.getCurrentNoteId(); + + const relationsToSave = self.relations() + .map(relation => relation()) + .filter(relation => relation.relationId !== "" || relation.name !== ""); + + const relations = await server.put('notes/' + noteId + '/relations', relationsToSave); + + self.relations(relations.map(ko.observable)); + + addLastEmptyRow(); + + infoService.showMessage("Relations have been saved."); + + noteDetailService.loadRelationList(); + }; + + function addLastEmptyRow() { + const relations = self.relations().filter(attr => attr().isDeleted === 0); + const last = relations.length === 0 ? null : relations[relations.length - 1](); + + if (!last || last.name.trim() !== "" || last.value !== "") { + self.relations.push(ko.observable({ + relationId: '', + name: '', + value: '', + isDeleted: 0, + position: 0 + })); + } + } + + this.relationChanged = function (data, event) { + addLastEmptyRow(); + + const relation = self.getTargetRelation(event.target); + + relation.valueHasMutated(); + }; + + this.isNotUnique = function(index) { + const cur = self.relations()[index](); + + if (cur.name.trim() === "") { + return false; + } + + for (let relations = self.relations(), i = 0; i < relations.length; i++) { + const relation = relations[i](); + + if (index !== i && cur.name === relation.name) { + return true; + } + } + + return false; + }; + + this.isEmptyName = function(index) { + const cur = self.relations()[index](); + + return cur.name.trim() === "" && (cur.relationId !== "" || cur.value !== ""); + }; + + this.getTargetRelation = function(target) { + const context = ko.contextFor(target); + const index = context.$index(); + + return self.relations()[index]; + } +} + +async function showDialog() { + glob.activeDialog = $dialog; + + await relationsModel.loadRelations(); + + $dialog.dialog({ + modal: true, + width: 800, + height: 500 + }); +} + +ko.applyBindings(relationsModel, document.getElementById('relations-dialog')); + +$(document).on('focus', '.relation-name', function (e) { + if (!$(this).hasClass("ui-autocomplete-input")) { + $(this).autocomplete({ + // shouldn't be required and autocomplete should just accept array of strings, but that fails + // because we have overriden filter() function in autocomplete.js + source: relationNames.map(relation => { + return { + relation: relation, + value: relation + } + }), + minLength: 0 + }); + } + + $(this).autocomplete("search", $(this).val()); +}); + +$(document).on('focus', '.relation-value', async function (e) { + if (!$(this).hasClass("ui-autocomplete-input")) { + const relationName = $(this).parent().parent().find('.relation-name').val(); + + if (relationName.trim() === "") { + return; + } + + const relationValues = await server.get('relations/values/' + encodeURIComponent(relationName)); + + if (relationValues.length === 0) { + return; + } + + $(this).autocomplete({ + // shouldn't be required and autocomplete should just accept array of strings, but that fails + // because we have overriden filter() function in autocomplete.js + source: relationValues.map(relation => { + return { + label: relation, + value: relation + } + }), + minLength: 0 + }); + } + + $(this).autocomplete("search", $(this).val()); +}); + +export default { + showDialog +}; \ No newline at end of file diff --git a/src/public/javascripts/services/entrypoints.js b/src/public/javascripts/services/entrypoints.js index d9bc7fd23..d25f79785 100644 --- a/src/public/javascripts/services/entrypoints.js +++ b/src/public/javascripts/services/entrypoints.js @@ -12,6 +12,7 @@ import recentChangesDialog from "../dialogs/recent_changes.js"; import sqlConsoleDialog from "../dialogs/sql_console.js"; import searchNotesService from "./search_notes.js"; import labelsDialog from "../dialogs/labels.js"; +import relationsDialog from "../dialogs/relations.js"; import protectedSessionService from "./protected_session.js"; function registerEntrypoints() { @@ -40,6 +41,9 @@ function registerEntrypoints() { $(".show-labels-button").click(labelsDialog.showDialog); utils.bindShortcut('alt+l', labelsDialog.showDialog); + $(".show-relations-button").click(relationsDialog.showDialog); + utils.bindShortcut('alt+r', relationsDialog.showDialog); + $("#options-button").click(optionsDialog.showDialog); utils.bindShortcut('alt+o', sqlConsoleDialog.showDialog); diff --git a/src/routes/api/relations.js b/src/routes/api/relations.js new file mode 100644 index 000000000..64ef88d2c --- /dev/null +++ b/src/routes/api/relations.js @@ -0,0 +1,63 @@ +"use strict"; + +const sql = require('../../services/sql'); +const relationService = require('../../services/relations'); +const repository = require('../../services/repository'); +const Relation = require('../../entities/relation'); + +async function getNoteRelations(req) { + const noteId = req.params.noteId; + + return await repository.getEntities("SELECT * FROM relations WHERE isDeleted = 0 AND sourceNoteId = ? ORDER BY position, dateCreated", [noteId]); +} + +async function updateNoteRelations(req) { + const noteId = req.params.noteId; + const relations = req.body; + + for (const relation of relations) { + let relationEntity; + + if (relation.labelId) { + relationEntity = await repository.getRelation(relation.relationId); + } + else { + // if it was "created" and then immediatelly deleted, we just don't create it at all + if (relation.isDeleted) { + continue; + } + + relationEntity = new Relation(); + relationEntity.sourceNoteId = noteId; + } + + relationEntity.name = relation.name; + relationEntity.targetNoteId = relation.targetNoteId; + relationEntity.position = relation.position; + relationEntity.isDeleted = relation.isDeleted; + + await relationEntity.save(); + } + + return await repository.getEntities("SELECT * FROM relations WHERE isDeleted = 0 AND sourceNoteId = ? ORDER BY position, dateCreated", [noteId]); +} + +async function getAllRelationNames() { + const names = await sql.getColumn("SELECT DISTINCT name FROM relations WHERE isDeleted = 0"); + + for (const relationName of relationService.BUILTIN_RELATIONS) { + if (!names.includes(relationName)) { + names.push(relationName); + } + } + + names.sort(); + + return names; +} + +module.exports = { + getNoteRelations, + updateNoteRelations, + getAllRelationNames +}; \ No newline at end of file diff --git a/src/routes/routes.js b/src/routes/routes.js index 0b6b01433..8d2169dcb 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -26,6 +26,7 @@ const anonymizationRoute = require('./api/anonymization'); const cleanupRoute = require('./api/cleanup'); const imageRoute = require('./api/image'); const labelsRoute = require('./api/labels'); +const relationsRoute = require('./api/relations'); const scriptRoute = require('./api/script'); const senderRoute = require('./api/sender'); const filesRoute = require('./api/file_upload'); @@ -137,6 +138,10 @@ function register(app) { apiRoute(GET, '/api/labels/names', labelsRoute.getAllLabelNames); apiRoute(GET, '/api/labels/values/:labelName', labelsRoute.getValuesForLabel); + apiRoute(GET, '/api/notes/:noteId/relations', relationsRoute.getNoteRelations); + apiRoute(PUT, '/api/notes/:noteId/relations', relationsRoute.updateNoteRelations); + apiRoute(GET, '/api/relations/names', relationsRoute.getAllRelationNames); + route(GET, '/api/images/:imageId/:filename', [auth.checkApiAuthOrElectron], imageRoute.returnImage); route(POST, '/api/images', [auth.checkApiAuthOrElectron, uploadMiddleware], imageRoute.uploadImage, apiResultHandler); diff --git a/src/services/app_info.js b/src/services/app_info.js index 99c63a630..10b6c9bf9 100644 --- a/src/services/app_info.js +++ b/src/services/app_info.js @@ -3,7 +3,7 @@ const build = require('./build'); const packageJson = require('../../package'); -const APP_DB_VERSION = 105; +const APP_DB_VERSION = 106; const SYNC_VERSION = 1; module.exports = { diff --git a/src/services/relations.js b/src/services/relations.js new file mode 100644 index 000000000..64f554289 --- /dev/null +++ b/src/services/relations.js @@ -0,0 +1,44 @@ +"use strict"; + +const repository = require('./repository'); +const Relation = require('../entities/relation'); + +const BUILTIN_RELATIONS = [ + 'exampleBuiltIn' +]; + +async function getNotesWithRelation(name, targetNoteId) { + let notes; + + if (targetNoteId !== undefined) { + notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN relations ON notes.noteId = relations.sourceNoteId + WHERE notes.isDeleted = 0 AND relations.isDeleted = 0 AND relations.name = ? AND relations.targetNoteId = ?`, [name, targetNoteId]); + } + else { + notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN relations ON notes.noteId = relations.sourceNoteId + WHERE notes.isDeleted = 0 AND relations.isDeleted = 0 AND relations.name = ?`, [name]); + } + + return notes; +} + +async function getNoteWithRelation(name, value) { + const notes = await getNotesWithRelation(name, value); + + return notes.length > 0 ? notes[0] : null; +} + +async function createRelation(sourceNoteId, name, targetNoteId) { + return await new Relation({ + sourceNoteId: sourceNoteId, + name: name, + targetNoteId: targetNoteId + }).save(); +} + +module.exports = { + getNotesWithRelation, + getNoteWithRelation, + createRelation, + BUILTIN_RELATIONS +}; \ No newline at end of file diff --git a/src/services/repository.js b/src/services/repository.js index ff9f299ce..0f5f5e042 100644 --- a/src/services/repository.js +++ b/src/services/repository.js @@ -41,6 +41,10 @@ async function getLabel(labelId) { return await getEntity("SELECT * FROM labels WHERE labelId = ?", [labelId]); } +async function getRelation(relationId) { + return await getEntity("SELECT * FROM relations WHERE relationId = ?", [relationId]); +} + async function getOption(name) { return await getEntity("SELECT * FROM options WHERE name = ?", [name]); } @@ -72,6 +76,7 @@ module.exports = { getBranch, getImage, getLabel, + getRelation, getOption, updateEntity, setEntityConstructor diff --git a/src/services/sync.js b/src/services/sync.js index 25799cd89..51b42c98e 100644 --- a/src/services/sync.js +++ b/src/services/sync.js @@ -236,6 +236,7 @@ const primaryKeys = { "images": "imageId", "note_images": "noteImageId", "labels": "labelId", + "relations": "relationId", "api_tokens": "apiTokenId", "options": "name" }; diff --git a/src/services/sync_table.js b/src/services/sync_table.js index 9b4e6ec16..c425fb514 100644 --- a/src/services/sync_table.js +++ b/src/services/sync_table.js @@ -42,6 +42,10 @@ async function addLabelSync(labelId, sourceId) { await addEntitySync("labels", labelId, sourceId); } +async function addRelationSync(relationId, sourceId) { + await addEntitySync("relations", relationId, sourceId); +} + async function addApiTokenSync(apiTokenId, sourceId) { await addEntitySync("api_tokens", apiTokenId, sourceId); } @@ -101,6 +105,7 @@ async function fillAllSyncRows() { await fillSyncRows("images", "imageId"); await fillSyncRows("note_images", "noteImageId"); await fillSyncRows("labels", "labelId"); + await fillSyncRows("relations", "relationId"); await fillSyncRows("api_tokens", "apiTokenId"); await fillSyncRows("options", "name", 'isSynced = 1'); } @@ -115,6 +120,7 @@ module.exports = { addImageSync, addNoteImageSync, addLabelSync, + addRelationSync, addApiTokenSync, addEntitySync, cleanupSyncRowsForMissingEntities, diff --git a/src/services/sync_update.js b/src/services/sync_update.js index f966cc458..d342849f0 100644 --- a/src/services/sync_update.js +++ b/src/services/sync_update.js @@ -33,6 +33,9 @@ async function updateEntity(sync, entity, sourceId) { else if (entityName === 'labels') { await updateLabel(entity, sourceId); } + else if (entityName === 'relations') { + await updateRelation(entity, sourceId); + } else if (entityName === 'api_tokens') { await updateApiToken(entity, sourceId); } @@ -185,6 +188,20 @@ async function updateLabel(entity, sourceId) { } } +async function updateRelation(entity, sourceId) { + const origRelation = await sql.getRow("SELECT * FROM relations WHERE relationId = ?", [entity.relationId]); + + if (!origRelation || origRelation.dateModified <= entity.dateModified) { + await sql.transactional(async () => { + await sql.replace("relations", entity); + + await syncTableService.addRelationSync(entity.relationId, sourceId); + }); + + log.info("Update/sync relation " + entity.relationId); + } +} + async function updateApiToken(entity, sourceId) { const apiTokenId = await sql.getRow("SELECT * FROM api_tokens WHERE apiTokenId = ?", [entity.apiTokenId]); diff --git a/src/views/index.ejs b/src/views/index.ejs index bc8c413c3..f4285df07 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -170,6 +170,7 @@ @@ -587,6 +588,50 @@ +
+ + + + + + + + + + + + + + + + + + + +
IDRelation nameTarget note
+ + + + + +
Duplicate relation.
+
Relation name can't be empty.
+
+ + + +
+ + + +