mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	implemented mirror relations
This commit is contained in:
		| @@ -40,10 +40,20 @@ class Attribute extends Entity { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @returns {Promise<Note|null>} | ||||
|      */ | ||||
|     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() { | ||||
|         if (this.type !== 'relation') { | ||||
|             throw new Error(`Attribute ${this.attributeId} is not relation`); | ||||
| @@ -53,9 +63,16 @@ class Attribute extends Entity { | ||||
|             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() { | ||||
|         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 : { | ||||
|                 multiplicityType: "singlevalue", | ||||
|                 mirrorRelation: "", | ||||
|                 isPromoted: true | ||||
|             }; | ||||
|  | ||||
| @@ -189,6 +190,7 @@ function AttributesModel() { | ||||
|                 }, | ||||
|                 relationDefinition: { | ||||
|                     multiplicityType: "singlevalue", | ||||
|                     mirrorRelation: "", | ||||
|                     isPromoted: true | ||||
|                 } | ||||
|             })); | ||||
|   | ||||
| @@ -60,7 +60,7 @@ async function showAttributes() { | ||||
|         const $inputCell = $("<td>").append($("<div>").addClass("input-group").append($input)); | ||||
|  | ||||
|         const $actionCell = $("<td>"); | ||||
|         const $multiplicityCell = $("<td>"); | ||||
|         const $multiplicityCell = $("<td>").addClass("multiplicity"); | ||||
|  | ||||
|         $tr | ||||
|             .append($labelCell) | ||||
| @@ -148,9 +148,14 @@ async function showAttributes() { | ||||
|             // 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 | ||||
|             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); | ||||
| @@ -162,7 +167,7 @@ async function showAttributes() { | ||||
|  | ||||
|         if (definition.multiplicityType === "multivalue") { | ||||
|             const addButton = $("<span>") | ||||
|                 .addClass("glyphicon glyphicon-plus pointer") | ||||
|                 .addClass("jam jam-plus pointer") | ||||
|                 .prop("title", "Add new attribute") | ||||
|                 .click(async () => { | ||||
|                     const $new = await createRow(definitionAttr, { | ||||
| @@ -178,7 +183,7 @@ async function showAttributes() { | ||||
|                 }); | ||||
|  | ||||
|             const removeButton = $("<span>") | ||||
|                 .addClass("glyphicon glyphicon-trash pointer") | ||||
|                 .addClass("jam jam-trash pointer") | ||||
|                 .prop("title", "Remove this attribute") | ||||
|                 .click(async () => { | ||||
|                     if (valueAttr.attributeId) { | ||||
| @@ -269,11 +274,9 @@ async function promotedAttributeChanged(event) { | ||||
|         value = $attr.is(':checked') ? "true" : "false"; | ||||
|     } | ||||
|     else if ($attr.prop("attribute-type") === "relation") { | ||||
|         const selectedPath = $attr.prop("data-selected-path"); | ||||
|         const selectedPath = $attr.getSelectedPath(); | ||||
|  | ||||
|         if (selectedPath) { | ||||
|             value = treeUtils.getNoteIdFromNotePath(selectedPath); | ||||
|         } | ||||
|         value = selectedPath ? treeUtils.getNoteIdFromNotePath(selectedPath) : ""; | ||||
|     } | ||||
|     else { | ||||
|         value = $attr.val(); | ||||
|   | ||||
| @@ -54,24 +54,34 @@ function initNoteAutocomplete($el) { | ||||
|             $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; | ||||
| } | ||||
|  | ||||
| $.fn.getSelectedPath = function() { | ||||
|     if (!$(this).val().trim()) { | ||||
|         return ""; | ||||
|     } | ||||
|     else { | ||||
|         return $(this).prop("data-selected-path"); | ||||
|     } | ||||
| }; | ||||
|  | ||||
| ko.bindingHandlers.noteAutocomplete = { | ||||
|     init: function(element, valueAccessor, allBindings, viewModel, bindingContext) { | ||||
|         initNoteAutocomplete($(element)); | ||||
|  | ||||
|         $(element).on('autocomplete:selected', function(event, suggestion, dataset) { | ||||
|             bindingContext.$data.selectedPath = suggestion.path; | ||||
|             bindingContext.$data.selectedPath = $(element).val().trim() ? suggestion.path : ''; | ||||
|         }); | ||||
|     } | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|     initNoteAutocomplete, | ||||
|     autocompleteSource, | ||||
|     showRecentNotes | ||||
| } | ||||
| @@ -152,6 +152,11 @@ async function getRunPath(notePath) { | ||||
|  | ||||
|         if (childNoteId !== null) { | ||||
|             const child = await treeCache.getNote(childNoteId); | ||||
|  | ||||
|             if (!child) { | ||||
|                 console.log("Can't find " + childNoteId); | ||||
|             } | ||||
|  | ||||
|             const parents = await child.getParentNotes(); | ||||
|  | ||||
|             if (!parents) { | ||||
| @@ -609,7 +614,7 @@ $(window).bind('hashchange', function() { | ||||
|     const notePath = getNotePathFromAddress(); | ||||
|  | ||||
|     if (getCurrentNotePath() !== notePath) { | ||||
|         console.log("Switching to " + notePath + " because of hash change"); | ||||
|         console.debug("Switching to " + notePath + " because of hash change"); | ||||
|  | ||||
|         activateNote(notePath); | ||||
|     } | ||||
|   | ||||
| @@ -57,7 +57,7 @@ class TreeCache { | ||||
|  | ||||
|         return noteIds.map(noteId => { | ||||
|             if (!this.notes[noteId] && !silentNotFoundError) { | ||||
|                 messagingService.logError(`Can't find note ${noteId}`); | ||||
|                 messagingService.logError(`Can't find note "${noteId}"`); | ||||
|  | ||||
|                 return null; | ||||
|             } | ||||
|   | ||||
| @@ -521,6 +521,11 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| .algolia-autocomplete .aa-dropdown-menu .aa-suggestion p { | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| .algolia-autocomplete .aa-dropdown-menu .aa-suggestion.aa-cursor { | ||||
|     background-color: #B2D7FF; | ||||
| } | ||||
| @@ -544,4 +549,8 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th | ||||
|  | ||||
| .fancytree-custom-icon { | ||||
|     font-size: 1.3em; | ||||
| } | ||||
|  | ||||
| .multiplicity { | ||||
|     font-size: larger; | ||||
| } | ||||
| @@ -20,6 +20,10 @@ async function updateNoteAttribute(req) { | ||||
|         attribute = await repository.getAttribute(body.attributeId); | ||||
|     } | ||||
|     else { | ||||
|         if (body.type === 'relation' && !body.value.trim()) { | ||||
|             return {}; | ||||
|         } | ||||
|  | ||||
|         attribute = new Attribute(); | ||||
|         attribute.noteId = noteId; | ||||
|         attribute.name = body.name; | ||||
| @@ -30,7 +34,13 @@ async function updateNoteAttribute(req) { | ||||
|         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(); | ||||
|  | ||||
| @@ -81,11 +91,18 @@ async function updateNoteAttributes(req) { | ||||
|  | ||||
|         attributeEntity.type = attribute.type; | ||||
|         attributeEntity.name = attribute.name; | ||||
|         attributeEntity.value = attribute.value; | ||||
|         attributeEntity.position = attribute.position; | ||||
|         attributeEntity.isInheritable = attribute.isInheritable; | ||||
|         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(); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,7 @@ const sqlInit = require('./sql_init'); | ||||
| const log = require('./log'); | ||||
| const messagingService = require('./messaging'); | ||||
| const syncMutexService = require('./sync_mutex'); | ||||
| const repository = require('./repository.js'); | ||||
| const cls = require('./cls'); | ||||
|  | ||||
| async function runCheck(query, errorText, errorList) { | ||||
| @@ -89,6 +90,17 @@ async function runSyncRowChecks(table, key, 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() { | ||||
|     const errorList = []; | ||||
|  | ||||
| @@ -221,6 +233,8 @@ async function runAllChecks() { | ||||
|         await checkTreeCycles(errorList); | ||||
|     } | ||||
|  | ||||
|     await fixEmptyRelationTargets(errorList); | ||||
|  | ||||
|     return errorList; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ const NOTE_TITLE_CHANGED = "NOTE_TITLE_CHANGED"; | ||||
| const ENTER_PROTECTED_SESSION = "ENTER_PROTECTED_SESSION"; | ||||
| const ENTITY_CREATED = "ENTITY_CREATED"; | ||||
| const ENTITY_CHANGED = "ENTITY_CHANGED"; | ||||
| const ENTITY_DELETED = "ENTITY_DELETED"; | ||||
| const CHILD_NOTE_CREATED = "CHILD_NOTE_CREATED"; | ||||
|  | ||||
| const eventListeners = {}; | ||||
| @@ -37,5 +38,6 @@ module.exports = { | ||||
|     ENTER_PROTECTED_SESSION, | ||||
|     ENTITY_CREATED, | ||||
|     ENTITY_CHANGED, | ||||
|     ENTITY_DELETED, | ||||
|     CHILD_NOTE_CREATED | ||||
| }; | ||||
| @@ -3,9 +3,10 @@ const scriptService = require('./script'); | ||||
| const treeService = require('./tree'); | ||||
| const messagingService = require('./messaging'); | ||||
| const log = require('./log'); | ||||
| const Attribute = require('../entities/attribute'); | ||||
|  | ||||
| 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) { | ||||
|         const scriptNote = await relation.getTargetNote(); | ||||
| @@ -56,4 +57,54 @@ eventService.subscribe(eventService.ENTITY_CREATED, async ({ entityName, entity | ||||
|  | ||||
| eventService.subscribe(eventService.CHILD_NOTE_CREATED, async ({ parentNote, 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)) { | ||||
|             await syncTableService.addEntitySync(entityName, primaryKey); | ||||
|  | ||||
|             if (isNewEntity) { | ||||
|                 await eventService.emit(eventService.ENTITY_CREATED, { | ||||
|                     entityName, | ||||
|                     entity | ||||
|                 }); | ||||
|             const eventPayload = { | ||||
|                 entityName, | ||||
|                 entity | ||||
|             }; | ||||
|  | ||||
|             if (isNewEntity && !entity.isDeleted) { | ||||
|                 await eventService.emit(eventService.ENTITY_CREATED, eventPayload); | ||||
|             } | ||||
|  | ||||
|             // it seems to be better to handle deletion with a separate event | ||||
|             if (!entity.isDeleted) { | ||||
|                 await eventService.emit(eventService.ENTITY_CHANGED, { | ||||
|                     entityName, | ||||
|                     entity | ||||
|                 }); | ||||
|             } | ||||
|             // it seems to be better to handle deletion and update separately | ||||
|             await eventService.emit(entity.isDeleted ? eventService.ENTITY_DELETED : eventService.ENTITY_CHANGED, eventPayload); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|   | ||||
| @@ -68,6 +68,12 @@ | ||||
|                            data-bind="checked: relationDefinition.isPromoted"/> | ||||
|                       Promoted | ||||
|                     </label> | ||||
|                     <br/> | ||||
|                     <label> | ||||
|                       Mirror relation: | ||||
|  | ||||
|                       <input type="text" value="true" class="attribute-name" data-bind="value: relationDefinition.mirrorRelation"/> | ||||
|                     </label> | ||||
|                   </div> | ||||
|                 </td> | ||||
|                 <td title="Inheritable relations are automatically inherited to the child notes"> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user