mirror of
https://github.com/zadam/trilium.git
synced 2025-03-01 14:22:32 +01:00
implemented mirror relations
This commit is contained in:
parent
e7cea59ba7
commit
21d3b0c9d8
@ -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();
|
||||||
|
|
||||||
|
if (notePath) {
|
||||||
treeService.activateNote(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}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.value.trim()) {
|
||||||
attribute.value = body.value;
|
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">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user