mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 02:16:05 +01:00 
			
		
		
		
	implemented mirror relations
This commit is contained in:
		| @@ -40,10 +40,20 @@ class Attribute extends Entity { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @returns {Promise<Note|null>} | ||||||
|  |      */ | ||||||
|     async getNote() { |     async getNote() { | ||||||
|         return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]); |         if (!this.__note) { | ||||||
|  |             this.__note = await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return this.__note; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @returns {Promise<Note|null>} | ||||||
|  |      */ | ||||||
|     async getTargetNote() { |     async getTargetNote() { | ||||||
|         if (this.type !== 'relation') { |         if (this.type !== 'relation') { | ||||||
|             throw new Error(`Attribute ${this.attributeId} is not relation`); |             throw new Error(`Attribute ${this.attributeId} is not relation`); | ||||||
| @@ -53,9 +63,16 @@ class Attribute extends Entity { | |||||||
|             return null; |             return null; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.value]); |         if (!this.__targetNote) { | ||||||
|  |             this.__targetNote = await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.value]); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return this.__targetNote; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @return {boolean} | ||||||
|  |      */ | ||||||
|     isDefinition() { |     isDefinition() { | ||||||
|         return this.type === 'label-definition' || this.type === 'relation-definition'; |         return this.type === 'label-definition' || this.type === 'relation-definition'; | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -71,6 +71,7 @@ function AttributesModel() { | |||||||
|  |  | ||||||
|             attr.relationDefinition = (attr.type === 'relation-definition' && attr.value) ? attr.value : { |             attr.relationDefinition = (attr.type === 'relation-definition' && attr.value) ? attr.value : { | ||||||
|                 multiplicityType: "singlevalue", |                 multiplicityType: "singlevalue", | ||||||
|  |                 mirrorRelation: "", | ||||||
|                 isPromoted: true |                 isPromoted: true | ||||||
|             }; |             }; | ||||||
|  |  | ||||||
| @@ -189,6 +190,7 @@ function AttributesModel() { | |||||||
|                 }, |                 }, | ||||||
|                 relationDefinition: { |                 relationDefinition: { | ||||||
|                     multiplicityType: "singlevalue", |                     multiplicityType: "singlevalue", | ||||||
|  |                     mirrorRelation: "", | ||||||
|                     isPromoted: true |                     isPromoted: true | ||||||
|                 } |                 } | ||||||
|             })); |             })); | ||||||
|   | |||||||
| @@ -60,7 +60,7 @@ async function showAttributes() { | |||||||
|         const $inputCell = $("<td>").append($("<div>").addClass("input-group").append($input)); |         const $inputCell = $("<td>").append($("<div>").addClass("input-group").append($input)); | ||||||
|  |  | ||||||
|         const $actionCell = $("<td>"); |         const $actionCell = $("<td>"); | ||||||
|         const $multiplicityCell = $("<td>"); |         const $multiplicityCell = $("<td>").addClass("multiplicity"); | ||||||
|  |  | ||||||
|         $tr |         $tr | ||||||
|             .append($labelCell) |             .append($labelCell) | ||||||
| @@ -148,9 +148,14 @@ async function showAttributes() { | |||||||
|             // ideally we'd use link instead of button which would allow tooltip preview, but |             // ideally we'd use link instead of button which would allow tooltip preview, but | ||||||
|             // we can't guarantee updating the link in the a element |             // we can't guarantee updating the link in the a element | ||||||
|             const $openButton = $("<button>").addClass("btn btn-sm").text("Open").click(() => { |             const $openButton = $("<button>").addClass("btn btn-sm").text("Open").click(() => { | ||||||
|                 const notePath = $input.prop("data-selected-path"); |                 const notePath = $input.getSelectedPath(); | ||||||
|  |  | ||||||
|                 treeService.activateNote(notePath); |                 if (notePath) { | ||||||
|  |                     treeService.activateNote(notePath); | ||||||
|  |                 } | ||||||
|  |                 else { | ||||||
|  |                     console.log("Empty note path, nothing to open."); | ||||||
|  |                 } | ||||||
|             }); |             }); | ||||||
|  |  | ||||||
|             $actionCell.append($openButton); |             $actionCell.append($openButton); | ||||||
| @@ -162,7 +167,7 @@ async function showAttributes() { | |||||||
|  |  | ||||||
|         if (definition.multiplicityType === "multivalue") { |         if (definition.multiplicityType === "multivalue") { | ||||||
|             const addButton = $("<span>") |             const addButton = $("<span>") | ||||||
|                 .addClass("glyphicon glyphicon-plus pointer") |                 .addClass("jam jam-plus pointer") | ||||||
|                 .prop("title", "Add new attribute") |                 .prop("title", "Add new attribute") | ||||||
|                 .click(async () => { |                 .click(async () => { | ||||||
|                     const $new = await createRow(definitionAttr, { |                     const $new = await createRow(definitionAttr, { | ||||||
| @@ -178,7 +183,7 @@ async function showAttributes() { | |||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
|             const removeButton = $("<span>") |             const removeButton = $("<span>") | ||||||
|                 .addClass("glyphicon glyphicon-trash pointer") |                 .addClass("jam jam-trash pointer") | ||||||
|                 .prop("title", "Remove this attribute") |                 .prop("title", "Remove this attribute") | ||||||
|                 .click(async () => { |                 .click(async () => { | ||||||
|                     if (valueAttr.attributeId) { |                     if (valueAttr.attributeId) { | ||||||
| @@ -269,11 +274,9 @@ async function promotedAttributeChanged(event) { | |||||||
|         value = $attr.is(':checked') ? "true" : "false"; |         value = $attr.is(':checked') ? "true" : "false"; | ||||||
|     } |     } | ||||||
|     else if ($attr.prop("attribute-type") === "relation") { |     else if ($attr.prop("attribute-type") === "relation") { | ||||||
|         const selectedPath = $attr.prop("data-selected-path"); |         const selectedPath = $attr.getSelectedPath(); | ||||||
|  |  | ||||||
|         if (selectedPath) { |         value = selectedPath ? treeUtils.getNoteIdFromNotePath(selectedPath) : ""; | ||||||
|             value = treeUtils.getNoteIdFromNotePath(selectedPath); |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|     else { |     else { | ||||||
|         value = $attr.val(); |         value = $attr.val(); | ||||||
|   | |||||||
| @@ -54,24 +54,34 @@ function initNoteAutocomplete($el) { | |||||||
|             $el.prop("data-selected-path", suggestion.path); |             $el.prop("data-selected-path", suggestion.path); | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         $el.getSelectedPath = () => $el.prop("data-selected-path"); |         $el.on('autocomplete:closed', () => { | ||||||
|  |             $el.prop("data-selected-path", ""); | ||||||
|  |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return $el; |     return $el; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | $.fn.getSelectedPath = function() { | ||||||
|  |     if (!$(this).val().trim()) { | ||||||
|  |         return ""; | ||||||
|  |     } | ||||||
|  |     else { | ||||||
|  |         return $(this).prop("data-selected-path"); | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
| ko.bindingHandlers.noteAutocomplete = { | ko.bindingHandlers.noteAutocomplete = { | ||||||
|     init: function(element, valueAccessor, allBindings, viewModel, bindingContext) { |     init: function(element, valueAccessor, allBindings, viewModel, bindingContext) { | ||||||
|         initNoteAutocomplete($(element)); |         initNoteAutocomplete($(element)); | ||||||
|  |  | ||||||
|         $(element).on('autocomplete:selected', function(event, suggestion, dataset) { |         $(element).on('autocomplete:selected', function(event, suggestion, dataset) { | ||||||
|             bindingContext.$data.selectedPath = suggestion.path; |             bindingContext.$data.selectedPath = $(element).val().trim() ? suggestion.path : ''; | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|     initNoteAutocomplete, |     initNoteAutocomplete, | ||||||
|     autocompleteSource, |  | ||||||
|     showRecentNotes |     showRecentNotes | ||||||
| } | } | ||||||
| @@ -152,6 +152,11 @@ async function getRunPath(notePath) { | |||||||
|  |  | ||||||
|         if (childNoteId !== null) { |         if (childNoteId !== null) { | ||||||
|             const child = await treeCache.getNote(childNoteId); |             const child = await treeCache.getNote(childNoteId); | ||||||
|  |  | ||||||
|  |             if (!child) { | ||||||
|  |                 console.log("Can't find " + childNoteId); | ||||||
|  |             } | ||||||
|  |  | ||||||
|             const parents = await child.getParentNotes(); |             const parents = await child.getParentNotes(); | ||||||
|  |  | ||||||
|             if (!parents) { |             if (!parents) { | ||||||
| @@ -609,7 +614,7 @@ $(window).bind('hashchange', function() { | |||||||
|     const notePath = getNotePathFromAddress(); |     const notePath = getNotePathFromAddress(); | ||||||
|  |  | ||||||
|     if (getCurrentNotePath() !== notePath) { |     if (getCurrentNotePath() !== notePath) { | ||||||
|         console.log("Switching to " + notePath + " because of hash change"); |         console.debug("Switching to " + notePath + " because of hash change"); | ||||||
|  |  | ||||||
|         activateNote(notePath); |         activateNote(notePath); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -57,7 +57,7 @@ class TreeCache { | |||||||
|  |  | ||||||
|         return noteIds.map(noteId => { |         return noteIds.map(noteId => { | ||||||
|             if (!this.notes[noteId] && !silentNotFoundError) { |             if (!this.notes[noteId] && !silentNotFoundError) { | ||||||
|                 messagingService.logError(`Can't find note ${noteId}`); |                 messagingService.logError(`Can't find note "${noteId}"`); | ||||||
|  |  | ||||||
|                 return null; |                 return null; | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -521,6 +521,11 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th | |||||||
|     margin: 0; |     margin: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .algolia-autocomplete .aa-dropdown-menu .aa-suggestion p { | ||||||
|  |     padding: 0; | ||||||
|  |     margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
| .algolia-autocomplete .aa-dropdown-menu .aa-suggestion.aa-cursor { | .algolia-autocomplete .aa-dropdown-menu .aa-suggestion.aa-cursor { | ||||||
|     background-color: #B2D7FF; |     background-color: #B2D7FF; | ||||||
| } | } | ||||||
| @@ -545,3 +550,7 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th | |||||||
| .fancytree-custom-icon { | .fancytree-custom-icon { | ||||||
|     font-size: 1.3em; |     font-size: 1.3em; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .multiplicity { | ||||||
|  |     font-size: larger; | ||||||
|  | } | ||||||
| @@ -20,6 +20,10 @@ async function updateNoteAttribute(req) { | |||||||
|         attribute = await repository.getAttribute(body.attributeId); |         attribute = await repository.getAttribute(body.attributeId); | ||||||
|     } |     } | ||||||
|     else { |     else { | ||||||
|  |         if (body.type === 'relation' && !body.value.trim()) { | ||||||
|  |             return {}; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         attribute = new Attribute(); |         attribute = new Attribute(); | ||||||
|         attribute.noteId = noteId; |         attribute.noteId = noteId; | ||||||
|         attribute.name = body.name; |         attribute.name = body.name; | ||||||
| @@ -30,7 +34,13 @@ async function updateNoteAttribute(req) { | |||||||
|         return [400, `Attribute ${body.attributeId} is not owned by ${noteId}`]; |         return [400, `Attribute ${body.attributeId} is not owned by ${noteId}`]; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     attribute.value = body.value; |     if (body.value.trim()) { | ||||||
|  |         attribute.value = body.value; | ||||||
|  |     } | ||||||
|  |     else { | ||||||
|  |         // relations should never have empty target | ||||||
|  |         attribute.isDeleted = true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     await attribute.save(); |     await attribute.save(); | ||||||
|  |  | ||||||
| @@ -81,11 +91,18 @@ async function updateNoteAttributes(req) { | |||||||
|  |  | ||||||
|         attributeEntity.type = attribute.type; |         attributeEntity.type = attribute.type; | ||||||
|         attributeEntity.name = attribute.name; |         attributeEntity.name = attribute.name; | ||||||
|         attributeEntity.value = attribute.value; |  | ||||||
|         attributeEntity.position = attribute.position; |         attributeEntity.position = attribute.position; | ||||||
|         attributeEntity.isInheritable = attribute.isInheritable; |         attributeEntity.isInheritable = attribute.isInheritable; | ||||||
|         attributeEntity.isDeleted = attribute.isDeleted; |         attributeEntity.isDeleted = attribute.isDeleted; | ||||||
|  |  | ||||||
|  |         if (attributeEntity.type === 'relation' && !attributeEntity.value.trim()) { | ||||||
|  |             // relation should never have empty target | ||||||
|  |             attributeEntity.isDeleted = true; | ||||||
|  |         } | ||||||
|  |         else { | ||||||
|  |             attributeEntity.value = attribute.value; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         await attributeEntity.save(); |         await attributeEntity.save(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ const sqlInit = require('./sql_init'); | |||||||
| const log = require('./log'); | const log = require('./log'); | ||||||
| const messagingService = require('./messaging'); | const messagingService = require('./messaging'); | ||||||
| const syncMutexService = require('./sync_mutex'); | const syncMutexService = require('./sync_mutex'); | ||||||
|  | const repository = require('./repository.js'); | ||||||
| const cls = require('./cls'); | const cls = require('./cls'); | ||||||
|  |  | ||||||
| async function runCheck(query, errorText, errorList) { | async function runCheck(query, errorText, errorList) { | ||||||
| @@ -89,6 +90,17 @@ async function runSyncRowChecks(table, key, errorList) { | |||||||
|         `Missing ${table} records for existing sync rows`, errorList); |         `Missing ${table} records for existing sync rows`, errorList); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | async function fixEmptyRelationTargets(errorList) { | ||||||
|  |     const emptyRelations = await repository.getEntities("SELECT * FROM attributes WHERE isDeleted = 0 AND type = 'relation' AND value = ''"); | ||||||
|  |  | ||||||
|  |     for (const relation of emptyRelations) { | ||||||
|  |         relation.isDeleted = true; | ||||||
|  |         await relation.save(); | ||||||
|  |  | ||||||
|  |         errorList.push(`Relation ${relation.attributeId} of name "${relation.name} has empty target. Autofixed.`); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| async function runAllChecks() { | async function runAllChecks() { | ||||||
|     const errorList = []; |     const errorList = []; | ||||||
|  |  | ||||||
| @@ -221,6 +233,8 @@ async function runAllChecks() { | |||||||
|         await checkTreeCycles(errorList); |         await checkTreeCycles(errorList); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     await fixEmptyRelationTargets(errorList); | ||||||
|  |  | ||||||
|     return errorList; |     return errorList; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ const NOTE_TITLE_CHANGED = "NOTE_TITLE_CHANGED"; | |||||||
| const ENTER_PROTECTED_SESSION = "ENTER_PROTECTED_SESSION"; | const ENTER_PROTECTED_SESSION = "ENTER_PROTECTED_SESSION"; | ||||||
| const ENTITY_CREATED = "ENTITY_CREATED"; | const ENTITY_CREATED = "ENTITY_CREATED"; | ||||||
| const ENTITY_CHANGED = "ENTITY_CHANGED"; | const ENTITY_CHANGED = "ENTITY_CHANGED"; | ||||||
|  | const ENTITY_DELETED = "ENTITY_DELETED"; | ||||||
| const CHILD_NOTE_CREATED = "CHILD_NOTE_CREATED"; | const CHILD_NOTE_CREATED = "CHILD_NOTE_CREATED"; | ||||||
|  |  | ||||||
| const eventListeners = {}; | const eventListeners = {}; | ||||||
| @@ -37,5 +38,6 @@ module.exports = { | |||||||
|     ENTER_PROTECTED_SESSION, |     ENTER_PROTECTED_SESSION, | ||||||
|     ENTITY_CREATED, |     ENTITY_CREATED, | ||||||
|     ENTITY_CHANGED, |     ENTITY_CHANGED, | ||||||
|  |     ENTITY_DELETED, | ||||||
|     CHILD_NOTE_CREATED |     CHILD_NOTE_CREATED | ||||||
| }; | }; | ||||||
| @@ -3,9 +3,10 @@ const scriptService = require('./script'); | |||||||
| const treeService = require('./tree'); | const treeService = require('./tree'); | ||||||
| const messagingService = require('./messaging'); | const messagingService = require('./messaging'); | ||||||
| const log = require('./log'); | const log = require('./log'); | ||||||
|  | const Attribute = require('../entities/attribute'); | ||||||
|  |  | ||||||
| async function runAttachedRelations(note, relationName, originEntity) { | async function runAttachedRelations(note, relationName, originEntity) { | ||||||
|     const runRelations = (await note.getRelations()).filter(relation => relation.name === relationName); |     const runRelations = await note.getRelations(relationName); | ||||||
|  |  | ||||||
|     for (const relation of runRelations) { |     for (const relation of runRelations) { | ||||||
|         const scriptNote = await relation.getTargetNote(); |         const scriptNote = await relation.getTargetNote(); | ||||||
| @@ -57,3 +58,53 @@ eventService.subscribe(eventService.ENTITY_CREATED, async ({ entityName, entity | |||||||
| eventService.subscribe(eventService.CHILD_NOTE_CREATED, async ({ parentNote, childNote }) => { | eventService.subscribe(eventService.CHILD_NOTE_CREATED, async ({ parentNote, childNote }) => { | ||||||
|     await runAttachedRelations(parentNote, 'runOnChildNoteCreation', childNote); |     await runAttachedRelations(parentNote, 'runOnChildNoteCreation', childNote); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | async function processMirrorRelations(entityName, entity, handler) { | ||||||
|  |     if (entityName === 'attributes' && entity.type === 'relation') { | ||||||
|  |         const note = await entity.getNote(); | ||||||
|  |         const attributes = (await note.getAttributes(entity.name)).filter(relation => relation.type === 'relation-definition'); | ||||||
|  |  | ||||||
|  |         for (const attribute of attributes) { | ||||||
|  |             const definition = attribute.value; | ||||||
|  |  | ||||||
|  |             if (definition.mirrorRelation && definition.mirrorRelation.trim()) { | ||||||
|  |                 const targetNote = await entity.getTargetNote(); | ||||||
|  |  | ||||||
|  |                 await handler(definition, note, targetNote); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | eventService.subscribe(eventService.ENTITY_CHANGED, async ({ entityName, entity }) => { | ||||||
|  |     await processMirrorRelations(entityName, entity, async (definition, note, targetNote) => { | ||||||
|  |         // we need to make sure that also target's mirror attribute exists and if note, then create it | ||||||
|  |         if (!await targetNote.hasRelation(definition.mirrorRelation)) { | ||||||
|  |             await new Attribute({ | ||||||
|  |                 noteId: targetNote.noteId, | ||||||
|  |                 type: 'relation', | ||||||
|  |                 name: definition.mirrorRelation, | ||||||
|  |                 value: note.noteId, | ||||||
|  |                 isInheritable: entity.isInheritable | ||||||
|  |             }).save(); | ||||||
|  |  | ||||||
|  |             targetNote.invalidateAttributeCache(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | eventService.subscribe(eventService.ENTITY_DELETED, async ({ entityName, entity }) => { | ||||||
|  |     await processMirrorRelations(entityName, entity, async (definition, note, targetNote) => { | ||||||
|  |         // if one mirror attribute is deleted then the other should be deleted as well | ||||||
|  |         const relations = await targetNote.getRelations(definition.mirrorRelation); | ||||||
|  |  | ||||||
|  |         for (const relation of relations) { | ||||||
|  |             relation.isDeleted = true; | ||||||
|  |             await relation.save(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (relations.length > 0) { | ||||||
|  |             targetNote.invalidateAttributeCache(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | }); | ||||||
| @@ -96,20 +96,17 @@ async function updateEntity(entity) { | |||||||
|         if (entity.isChanged && (entityName !== 'options' || entity.isSynced)) { |         if (entity.isChanged && (entityName !== 'options' || entity.isSynced)) { | ||||||
|             await syncTableService.addEntitySync(entityName, primaryKey); |             await syncTableService.addEntitySync(entityName, primaryKey); | ||||||
|  |  | ||||||
|             if (isNewEntity) { |             const eventPayload = { | ||||||
|                 await eventService.emit(eventService.ENTITY_CREATED, { |                 entityName, | ||||||
|                     entityName, |                 entity | ||||||
|                     entity |             }; | ||||||
|                 }); |  | ||||||
|  |             if (isNewEntity && !entity.isDeleted) { | ||||||
|  |                 await eventService.emit(eventService.ENTITY_CREATED, eventPayload); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // it seems to be better to handle deletion with a separate event |             // it seems to be better to handle deletion and update separately | ||||||
|             if (!entity.isDeleted) { |             await eventService.emit(entity.isDeleted ? eventService.ENTITY_DELETED : eventService.ENTITY_CHANGED, eventPayload); | ||||||
|                 await eventService.emit(eventService.ENTITY_CHANGED, { |  | ||||||
|                     entityName, |  | ||||||
|                     entity |  | ||||||
|                 }); |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -68,6 +68,12 @@ | |||||||
|                            data-bind="checked: relationDefinition.isPromoted"/> |                            data-bind="checked: relationDefinition.isPromoted"/> | ||||||
|                       Promoted |                       Promoted | ||||||
|                     </label> |                     </label> | ||||||
|  |                     <br/> | ||||||
|  |                     <label> | ||||||
|  |                       Mirror relation: | ||||||
|  |  | ||||||
|  |                       <input type="text" value="true" class="attribute-name" data-bind="value: relationDefinition.mirrorRelation"/> | ||||||
|  |                     </label> | ||||||
|                   </div> |                   </div> | ||||||
|                 </td> |                 </td> | ||||||
|                 <td title="Inheritable relations are automatically inherited to the child notes"> |                 <td title="Inheritable relations are automatically inherited to the child notes"> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user