import server from "../../services/server.js"; import froca from "../../services/froca.js"; import linkService from "../../services/link.js"; import attributeAutocompleteService from "../../services/attribute_autocomplete.js"; import noteAutocompleteService from "../../services/note_autocomplete.js"; import promotedAttributeDefinitionParser from '../../services/promoted_attribute_definition_parser.js'; import NoteContextAwareWidget from "../note_context_aware_widget.js"; import SpacedUpdate from "../../services/spaced_update.js"; import utils from "../../services/utils.js"; import shortcutService from "../../services/shortcuts.js"; import appContext from "../../components/app_context.js"; const TPL = `
Name:
Value:
Target note:
Promoted:
Alias:
Multiplicity:
Type:
Precision:
digits
Inverse relation:
Inheritable:
`; const DISPLAYED_NOTES = 10; const ATTR_TITLES = { "label": "Label detail", "label-definition": "Label definition detail", "relation": "Relation detail", "relation-definition": "Relation definition detail" }; const ATTR_HELP = { "label": { "disableVersioning": "disables auto-versioning. Useful for e.g. large, but unimportant notes - e.g. large JS libraries used for scripting", "calendarRoot": "marks note which should be used as root for day notes. Only one should be marked as such.", "archived": "notes with this label won't be visible by default in search results (also in Jump To, Add Link dialogs etc).", "excludeFromExport": "notes (with their sub-tree) won't be included in any note export", "run": `defines on which events script should run. Possible values are: `, "runOnInstance": "Define which trilium instance should run this on. Default to all instances.", "runAtHour": "On which hour should this run. Should be used together with #run=hourly. Can be defined multiple times for more runs during the day.", "disableInclusion": "scripts with this label won't be included into parent script execution.", "sorted": "keeps child notes sorted by title alphabetically", "sortDirection": "ASC (the default) or DESC", "sortFoldersFirst": "Folders (notes with children) should be sorted on top", "top": "keep given note on top in its parent (applies only on sorted parents)", "hidePromotedAttributes": "Hide promoted attributes on this note", "readOnly": "editor is in read only mode. Works only for text and code notes.", "autoReadOnlyDisabled": "text/code notes can be set automatically into read mode when they are too large. You can disable this behavior on per-note basis by adding this label to the note", "appCss": "marks CSS notes which are loaded into the Trilium application and can thus be used to modify Trilium's looks.", "appTheme": "marks CSS notes which are full Trilium themes and are thus available in Trilium options.", "cssClass": "value of this label is then added as CSS class to the node representing given note in the tree. This can be useful for advanced theming. Can be used in template notes.", "iconClass": "value of this label is added as a CSS class to the icon on the tree which can help visually distinguish the notes in the tree. Example might be bx bx-home - icons are taken from boxicons. Can be used in template notes.", "pageSize": "number of items per page in note listing", "customRequestHandler": 'see Custom request handler', "customResourceProvider": 'see Custom request handler', "widget": "marks this note as a custom widget which will be added to the Trilium component tree", "workspace": "marks this note as a workspace which allows easy hoisting", "workspaceIconClass": "defines box icon CSS class which will be used in tab when hoisted to this note", "workspaceTabBackgroundColor": "CSS color used in the note tab when hoisted to this note", "workspaceCalendarRoot": "Defines per-workspace calendar root", "workspaceTemplate": "This note will appear in the selection of available template when creating new note, but only when hoisted into a workspace containing this template", "searchHome": "new search notes will be created as children of this note", "workspaceSearchHome": "new search notes will be created as children of this note when hoisted to some ancestor of this workspace note", "inbox": "default inbox location for new notes - when you create a note using \"new note\" button in the sidebar, notes will be created as child notes in the note marked as with #inbox label.", "workspaceInbox": "default inbox location for new notes when hoisted to some ancestor of this workspace note", "sqlConsoleHome": "default location of SQL console notes", "bookmarkFolder": "note with this label will appear in bookmarks as folder (allowing access to its children)", "shareHiddenFromTree": "this note is hidden from left navigation tree, but still accessible with its URL", "shareExternalLink": "note will act as a link to an external website in the share tree", "shareAlias": "define an alias using which the note will be available under https://your_trilium_host/share/[your_alias]", "shareOmitDefaultCss": "default share page CSS will be omitted. Use when you make extensive styling changes.", "shareRoot": "marks note which is served on /share root.", "shareDescription": "define text to be added to the HTML meta tag for description", "shareRaw": "note will be served in its raw format, without HTML wrapper", "shareDisallowRobotIndexing": `will forbid robot indexing of this note via X-Robots-Tag: noindex header`, "shareCredentials": "require credentials to access this shared note. Value is expected to be in format 'username:password'. Don't forget to make this inheritable to apply to child-notes/images.", "shareIndex": "note with this this label will list all roots of shared notes", "displayRelations": "comma delimited names of relations which should be displayed. All other ones will be hidden.", "hideRelations": "comma delimited names of relations which should be hidden. All other ones will be displayed.", "titleTemplate": `default title of notes created as children of this note. The value is evaluated as JavaScript string and thus can be enriched with dynamic content via the injected now and parentNote variables. Examples: See wiki with details, API docs for parentNote and now for details.`, "template": "This note will appear in the selection of available template when creating new note", "toc": "#toc or #toc=show will force the Table of Contents to be shown, #toc=hide will force hiding it. If the label doesn't exist, the global setting is observed", "color": "defines color of the note in note tree, links etc. Use any valid CSS color value like 'red' or #a13d5f", "keyboardShortcut": "Defines a keyboard shortcut which will immediately jump to this note. Example: 'ctrl+alt+e'. Requires frontend reload for the change to take effect.", "keepCurrentHoisting": "Opening this link won't change hoisting even if the note is not displayable in the current hoisted subtree.", "executeButton": "Title of the button which will execute the current code note", "executeDescription": "Longer description of the current code note displayed together with the execute button", "excludeFromNoteMap": "Notes with this label will be hidden from the Note Map", "newNotesOnTop": "New notes will be created at the top of the parent note, not on the bottom.", "hideHighlightWidget": "Hide Hightlight List widget" }, "relation": { "runOnNoteCreation": "executes when note is created on backend. Use this relation if you want to run the script for all notes created under a specific subtree. In that case, create it on the subtree root note and make it inheritable. A new note created within the subtree (any depth) will trigger the script.", "runOnChildNoteCreation": "executes when new note is created under the note where this relation is defined", "runOnNoteTitleChange": "executes when note title is changed (includes note creation as well)", "runOnNoteContentChange": "executes when note content is changed (includes note creation as well).", "runOnNoteChange": "executes when note is changed (includes note creation as well). Does not include content changes", "runOnNoteDeletion": "executes when note is being deleted", "runOnBranchCreation": "executes when a branch is created. Branch is a link between parent note and child note and is created e.g. when cloning or moving note.", "runOnBranchChange": "executes when a branch is updated.", "runOnBranchDeletion": "executes when a branch is deleted. Branch is a link between parent note and child note and is deleted e.g. when moving note (old branch/link is deleted).", "runOnAttributeCreation": "executes when new attribute is created for the note which defines this relation", "runOnAttributeChange": " executes when the attribute is changed of a note which defines this relation. This is triggered also when the attribute is deleted", "template": "note's attributes will be inherited even without a parent-child relationship, note's content and subtree will be added to instance notes if empty. See documentation for details.", "inherit": "note's attributes will be inherited even without a parent-child relationship. See template relation for a similar concept. See attribute inheritance in the documentation.", "renderNote": 'notes of type "render HTML note" will be rendered using a code note (HTML or script) and it is necessary to point using this relation to which note should be rendered', "widget": "target of this relation will be executed and rendered as a widget in the sidebar", "shareCss": "CSS note which will be injected into the share page. CSS note must be in the shared sub-tree as well. Consider using 'shareHiddenFromTree' and 'shareOmitDefaultCss' as well.", "shareJs": "JavaScript note which will be injected into the share page. JS note must be in the shared sub-tree as well. Consider using 'shareHiddenFromTree'.", "shareTemplate": "Embedded JavaScript note that will be used as the template for displaying the shared note. Falls back to the default template. Consider using 'shareHiddenFromTree'.", "shareFavicon": "Favicon note to be set in the shared page. Typically you want to set it to share root and make it inheritable. Favicon note must be in the shared sub-tree as well. Consider using 'shareHiddenFromTree'.", } }; export default class AttributeDetailWidget extends NoteContextAwareWidget { async refresh() { // switching note/tab should close the widget this.hide(); } doRender() { this.relatedNotesSpacedUpdate = new SpacedUpdate(async () => this.updateRelatedNotes(), 1000); this.$widget = $(TPL); shortcutService.bindElShortcut(this.$widget, 'ctrl+return', () => this.saveAndClose()); shortcutService.bindElShortcut(this.$widget, 'esc', () => this.cancelAndClose()); this.$title = this.$widget.find('.attr-detail-title'); this.$inputName = this.$widget.find('.attr-input-name'); this.$inputName.on('input', ev => { if (!ev.originalEvent?.isComposing) { // https://github.com/zadam/trilium/pull/3812 this.userEditedAttribute(); } }); this.$inputName.on('change', () => this.userEditedAttribute()); this.$inputName.on('autocomplete:closed', () => this.userEditedAttribute()); this.$inputName.on('focus', () => { attributeAutocompleteService.initAttributeNameAutocomplete({ $el: this.$inputName, attributeType: () => ['relation', 'relation-definition'].includes(this.attrType) ? 'relation' : 'label', open: true }); }); this.$rowValue = this.$widget.find('.attr-row-value'); this.$inputValue = this.$widget.find('.attr-input-value'); this.$inputValue.on('input', ev => { if (!ev.originalEvent?.isComposing) { // https://github.com/zadam/trilium/pull/3812 this.userEditedAttribute(); } }); this.$inputValue.on('change', () => this.userEditedAttribute()); this.$inputValue.on('autocomplete:closed', () => this.userEditedAttribute()); this.$inputValue.on('focus', () => { attributeAutocompleteService.initLabelValueAutocomplete({ $el: this.$inputValue, open: true, nameCallback: () => this.$inputName.val() }); }); this.$rowPromoted = this.$widget.find('.attr-row-promoted'); this.$inputPromoted = this.$widget.find('.attr-input-promoted'); this.$inputPromoted.on('change', () => this.userEditedAttribute()); this.$rowPromotedAlias = this.$widget.find('.attr-row-promoted-alias'); this.$inputPromotedAlias = this.$widget.find('.attr-input-promoted-alias'); this.$inputPromotedAlias.on('change', () => this.userEditedAttribute()); this.$rowMultiplicity = this.$widget.find('.attr-row-multiplicity'); this.$inputMultiplicity = this.$widget.find('.attr-input-multiplicity'); this.$inputMultiplicity.on('change', () => this.userEditedAttribute()); this.$rowLabelType = this.$widget.find('.attr-row-label-type'); this.$inputLabelType = this.$widget.find('.attr-input-label-type'); this.$inputLabelType.on('change', () => this.userEditedAttribute()); this.$rowNumberPrecision = this.$widget.find('.attr-row-number-precision'); this.$inputNumberPrecision = this.$widget.find('.attr-input-number-precision'); this.$inputNumberPrecision.on('change', () => this.userEditedAttribute()); this.$rowInverseRelation = this.$widget.find('.attr-row-inverse-relation'); this.$inputInverseRelation = this.$widget.find('.attr-input-inverse-relation'); this.$inputInverseRelation.on('input', ev => { if (!ev.originalEvent?.isComposing) { // https://github.com/zadam/trilium/pull/3812 this.userEditedAttribute(); } }); this.$rowTargetNote = this.$widget.find('.attr-row-target-note'); this.$inputTargetNote = this.$widget.find('.attr-input-target-note'); noteAutocompleteService.initNoteAutocomplete(this.$inputTargetNote, {allowCreatingNotes: true}) .on('autocomplete:noteselected', (event, suggestion, dataset) => { if (!suggestion.notePath) { return false; } const pathChunks = suggestion.notePath.split('/'); this.attribute.value = pathChunks[pathChunks.length - 1]; // noteId this.triggerCommand('updateAttributeList', { attributes: this.allAttributes }); this.updateRelatedNotes(); }); this.$inputInheritable = this.$widget.find('.attr-input-inheritable'); this.$inputInheritable.on('change', () => this.userEditedAttribute()); this.$closeAttrDetailButton = this.$widget.find('.close-attr-detail-button'); this.$closeAttrDetailButton.on('click', () => this.cancelAndClose()); this.$attrIsOwnedBy = this.$widget.find('.attr-is-owned-by'); this.$attrSaveDeleteButtonContainer = this.$widget.find('.attr-save-delete-button-container'); this.$saveAndCloseButton = this.$widget.find('.attr-save-changes-and-close-button'); this.$saveAndCloseButton.on('click', () => this.saveAndClose()); this.$deleteButton = this.$widget.find('.attr-delete-button'); this.$deleteButton.on('click', async () => { await this.triggerCommand('updateAttributeList', { attributes: this.allAttributes.filter(attr => attr !== this.attribute) }); await this.triggerCommand('saveAttributes'); this.hide(); }); this.$attrHelp = this.$widget.find('.attr-help'); this.$relatedNotesContainer = this.$widget.find('.related-notes-container'); this.$relatedNotesTitle = this.$relatedNotesContainer.find('.related-notes-tile'); this.$relatedNotesList = this.$relatedNotesContainer.find('.related-notes-list'); this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find('.related-notes-more-notes'); $(window).on('mousedown', e => { if (!$(e.target).closest(this.$widget[0]).length && !$(e.target).closest(".algolia-autocomplete").length && !$(e.target).closest("#context-menu-container").length) { this.hide(); } }); } async showAttributeDetail({allAttributes, attribute, isOwned, x, y, focus}) { if (!attribute) { this.hide(); return; } utils.saveFocusedElement(); this.attrType = this.getAttrType(attribute); const attrName = this.attrType === 'label-definition' ? attribute.name.substr(6) : (this.attrType === 'relation-definition' ? attribute.name.substr(9) : attribute.name); const definition = this.attrType.endsWith('-definition') ? promotedAttributeDefinitionParser.parse(attribute.value) : {}; this.$title.text(ATTR_TITLES[this.attrType]); this.allAttributes = allAttributes; this.attribute = attribute; // can be slightly slower so just make it async this.updateRelatedNotes(); this.$attrSaveDeleteButtonContainer.toggle(!!isOwned); if (isOwned) { this.$attrIsOwnedBy.hide(); } else { this.$attrIsOwnedBy .show() .empty() .append(attribute.type === 'label' ? 'Label' : 'Relation') .append(' is owned by note ') .append(await linkService.createLink(attribute.noteId)) } this.$inputName .val(attrName) .attr('readonly', () => !isOwned); this.$rowValue.toggle(this.attrType === 'label'); this.$rowTargetNote.toggle(this.attrType === 'relation'); this.$rowPromoted.toggle(['label-definition', 'relation-definition'].includes(this.attrType)); this.$inputPromoted .prop("checked", !!definition.isPromoted) .attr('disabled', () => !isOwned); this.$rowPromotedAlias.toggle(!!definition.isPromoted); this.$inputPromotedAlias .val(definition.promotedAlias) .attr('disabled', () => !isOwned); this.$rowMultiplicity.toggle(['label-definition', 'relation-definition'].includes(this.attrType)); this.$inputMultiplicity .val(definition.multiplicity) .attr('disabled', () => !isOwned); this.$rowLabelType.toggle(this.attrType === 'label-definition'); this.$inputLabelType .val(definition.labelType) .attr('disabled', () => !isOwned); this.$rowNumberPrecision.toggle(this.attrType === 'label-definition' && definition.labelType === 'number'); this.$inputNumberPrecision .val(definition.numberPrecision) .attr('disabled', () => !isOwned); this.$rowInverseRelation.toggle(this.attrType === 'relation-definition'); this.$inputInverseRelation .val(definition.inverseRelation) .attr('disabled', () => !isOwned); if (attribute.type === 'label') { this.$inputValue .val(attribute.value) .attr('readonly', () => !isOwned); } else if (attribute.type === 'relation') { this.$inputTargetNote .attr('readonly', () => !isOwned) .val("") .setSelectedNotePath(""); if (attribute.value) { const targetNote = await froca.getNote(attribute.value); if (targetNote) { this.$inputTargetNote .val(targetNote ? targetNote.title : "") .setSelectedNotePath(attribute.value); } } } this.$inputInheritable .prop("checked", !!attribute.isInheritable) .attr('disabled', () => !isOwned); this.updateHelp(); this.toggleInt(true); const offset = this.parent.$widget.offset(); const detPosition = this.getDetailPosition(x, offset); this.$widget .css("left", detPosition.left) .css("right", detPosition.right) .css("top", y - offset.top + 70) .css("max-height", this.$widget.outerHeight() + y > $(window).height() - 50 ? $(window).height() - y - 50 : 10000); if (focus === 'name') { this.$inputName .trigger('focus') .trigger('select'); } } getDetailPosition(x, offset) { let left = x - offset.left - this.$widget.outerWidth() / 2; let right = ""; if (left < 0) { left = 10; } else { const rightEdge = left + this.$widget.outerWidth(); if (rightEdge > this.parent.$widget.outerWidth() - 10) { left = ""; right = 10; } } return {left, right}; } async saveAndClose() { await this.triggerCommand('saveAttributes'); this.hide(); utils.focusSavedElement(); } async cancelAndClose() { await this.triggerCommand('reloadAttributes'); this.hide(); utils.focusSavedElement(); } userEditedAttribute() { this.updateAttributeInEditor(); this.updateHelp(); this.relatedNotesSpacedUpdate.scheduleUpdate(); } updateHelp() { const attrName = this.$inputName.val(); if (this.attrType in ATTR_HELP && attrName in ATTR_HELP[this.attrType]) { this.$attrHelp .empty() .append($("") .append($("").text(attrName)) .append(" - ") .append(ATTR_HELP[this.attrType][attrName]) ) .show(); } else { this.$attrHelp.empty().hide(); } } async updateRelatedNotes() { let {results, count} = await server.post('search-related', this.attribute); for (const res of results) { res.noteId = res.notePathArray[res.notePathArray.length - 1]; } results = results.filter(({noteId}) => noteId !== this.noteId); if (results.length === 0) { this.$relatedNotesContainer.hide(); } else { this.$relatedNotesContainer.show(); this.$relatedNotesTitle.text(`Other notes with ${this.attribute.type} name "${this.attribute.name}"`); this.$relatedNotesList.empty(); const displayedResults = results.length <= DISPLAYED_NOTES ? results : results.slice(0, DISPLAYED_NOTES); const displayedNotes = await froca.getNotes(displayedResults.map(res => res.noteId)); const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; for (const note of displayedNotes) { const notePath = note.getBestNotePathString(hoistedNoteId); const $noteLink = await linkService.createLink(notePath, {showNotePath: true}); this.$relatedNotesList.append( $("
  • ").append($noteLink) ); } if (results.length > DISPLAYED_NOTES) { this.$relatedNotesMoreNotes.show().text(`... and ${count - DISPLAYED_NOTES} more.`); } else { this.$relatedNotesMoreNotes.hide(); } } } getAttrType(attribute) { if (attribute.type === 'label') { if (attribute.name.startsWith('label:')) { return "label-definition"; } else if (attribute.name.startsWith('relation:')) { return "relation-definition"; } else { return "label"; } } else if (attribute.type === 'relation') { return "relation"; } else { this.$title.text(''); } } updateAttributeInEditor() { let attrName = this.$inputName.val(); if (!utils.isValidAttributeName(attrName)) { // invalid characters are simply ignored (from user perspective they are not even entered) attrName = utils.filterAttributeName(attrName); this.$inputName.val(attrName); } if (this.attrType === 'label-definition') { attrName = `label:${attrName}`; } else if (this.attrType === 'relation-definition') { attrName = `relation:${attrName}`; } this.attribute.name = attrName; this.attribute.isInheritable = this.$inputInheritable.is(":checked"); if (this.attrType.endsWith('-definition')) { this.attribute.value = this.buildDefinitionValue(); } else if (this.attrType === 'relation') { this.attribute.value = this.$inputTargetNote.getSelectedNoteId(); } else { this.attribute.value = this.$inputValue.val(); } this.triggerCommand('updateAttributeList', { attributes: this.allAttributes }); } buildDefinitionValue() { const props = []; if (this.$inputPromoted.is(":checked")) { props.push("promoted"); if (this.$inputPromotedAlias.val() !== '') { props.push(`alias=${this.$inputPromotedAlias.val()}`); } } props.push(this.$inputMultiplicity.val()); if (this.attrType === 'label-definition') { props.push(this.$inputLabelType.val()); if (this.$inputLabelType.val() === 'number' && this.$inputNumberPrecision.val() !== '') { props.push(`precision=${this.$inputNumberPrecision.val()}`); } } else if (this.attrType === 'relation-definition' && this.$inputInverseRelation.val().trim().length > 0) { const inverseRelationName = this.$inputInverseRelation.val(); props.push(`inverse=${utils.filterAttributeName(inverseRelationName)}`); } this.$rowNumberPrecision.toggle( this.attrType === 'label-definition' && this.$inputLabelType.val() === 'number'); this.$rowPromotedAlias.toggle(this.$inputPromoted.is(":checked")); return props.join(","); } hide() { this.toggleInt(false); } createLink(noteId) { return $("", { href: `#root/${noteId}`, class: 'reference-link' }); } async noteSwitched() { this.hide(); } }